diff --git a/README.md b/README.md index 2b880c9..9172bdb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ `ws4kp-linhanced` is a Linux-focused fork of [`netbymatt/ws4kp`](https://github.com/netbymatt/ws4kp) by `markmental`. +Current release: `v0.2.1` + It keeps the stronger, more stable foundation of the original `ws4kp` project while selectively incorporating international weather support and global map ideas from [`mwood77/ws4kp-international`](https://github.com/mwood77/ws4kp-international). The goal is not to become a kitchen-sink weather platform. The goal is a leaner, maintainable, Linux-oriented WeatherStar fork with a clear identity. This fork also explicitly embraces Slackware Linux / `weatherstar4k` branding as part of its mission. Broad platform neutrality is not a design goal here. @@ -47,6 +49,7 @@ Major features currently in this fork: * global RainViewer radar on a cached world basemap * global `Regional Observations` and nearby-city displays backed by expanded worldwide city coverage * `Latest Observations` screen for nearby city temperatures, conditions, and wind +* `Hazard List` screen with MySQL-backed server history for encountered NOAA and derived hazards * `Ground View` screen powered by nearby Windy webcams (requires API key, see below) * Travel Forecast rebuilt around region buckets with a global fallback * optional screen-specific audio playback for supported displays @@ -164,6 +167,10 @@ CREATE TABLE hazard_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` +The database retains full hazard history. The `Hazard List` UI shows only the latest 7 entries and only the most recent row per stored location. Nearby same-label locations are reconciled during updates to reduce duplicate location spam. + +In the current browser session, `Hazard List` updates immediately after new hazard history is synced. + ## Running Modes This fork supports two main runtime styles. @@ -182,6 +189,7 @@ This mode includes: * proxying and caching for weather/map requests * Fastfetch-backed Server Observations * MySQL-backed Hazard List history +* automatic ongoing-hazard rechecks every 10 minutes for both NOAA and derived hazards * better shared performance when multiple clients use the same instance ### Static Mode diff --git a/index.mjs b/index.mjs index 6f8b398..6e94477 100644 --- a/index.mjs +++ b/index.mjs @@ -23,6 +23,7 @@ import { discoverThemes } from './src/theme-discovery.mjs'; import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs'; import { checkHazardHistoryTable } from './src/mysql.mjs'; import { getHistory, updateHistory } from './src/hazard-history.mjs'; +import { startHazardHistoryWorker, stopHazardHistoryWorker } from './src/hazard-history-worker.mjs'; const execAsync = promisify(exec); @@ -204,12 +205,18 @@ const staticOptions = { // Weather.gov API proxy (catch-all for any Weather.gov API endpoint) // Skip setting up routes for the caching proxy server in static mode if (!process.env?.STATIC) { + let hazardHistoryReady = false; try { await checkHazardHistoryTable(); + hazardHistoryReady = true; } catch (error) { console.error(error.message); } + if (hazardHistoryReady) { + startHazardHistoryWorker(); + } + // Server info endpoint for fastfetch output (must be before /api/ weather proxy) app.get('/api/server-info', async (req, res) => { try { @@ -408,6 +415,7 @@ const server = app.listen(port, () => { // graceful shutdown const gracefulShutdown = () => { + stopHazardHistoryWorker(); server.close(() => { console.log('Server closed'); process.exit(0); diff --git a/package-lock.json b/package-lock.json index a47a764..4383aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ws4kp-linhanced", - "version": "0.2", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ws4kp-linhanced", - "version": "0.1.1", + "version": "0.2.1", "license": "MIT", "dependencies": { "dotenv": "^17.0.1", diff --git a/package.json b/package.json index 4a5e27e..46b104d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws4kp-linhanced", - "version": "0.2", + "version": "0.2.1", "description": "WeatherStar 4000+: Linhanced - A Linux-focused fork of the WeatherStar 4000+ project", "main": "index.mjs", "type": "module", diff --git a/server/scripts/modules/hazard-list.mjs b/server/scripts/modules/hazard-list.mjs index c1a7b4c..aaae729 100644 --- a/server/scripts/modules/hazard-list.mjs +++ b/server/scripts/modules/hazard-list.mjs @@ -8,11 +8,17 @@ class HazardList extends WeatherDisplay { constructor(navId, elemId) { super(navId, elemId, 'Hazard List', true); this.history = []; + this.handleHazardHistoryUpdated = this.handleHazardHistoryUpdated.bind(this); + window.addEventListener('hazard-history-updated', this.handleHazardHistoryUpdated); } async getData(weatherParameters, refresh) { const superResult = super.getData(weatherParameters, refresh); + await this.refreshHistoryNow(); + return superResult; + } + async refreshHistoryNow() { try { // Fetch hazard history from backend const response = await fetch(withBasePath('api/hazard-history')); @@ -37,8 +43,13 @@ class HazardList extends WeatherDisplay { this.setStatus(STATUS.failed); } } + } - return superResult; + async handleHazardHistoryUpdated() { + await this.refreshHistoryNow(); + if (this.active) { + this.drawCanvas(); + } } async drawCanvas() { diff --git a/server/scripts/modules/hazards.mjs b/server/scripts/modules/hazards.mjs index 6d844be..e8c4e62 100644 --- a/server/scripts/modules/hazards.mjs +++ b/server/scripts/modules/hazards.mjs @@ -290,13 +290,19 @@ class Hazards extends WeatherDisplay { })); // Send to backend - await fetch(withBasePath('api/hazard-history'), { + const response = await fetch(withBasePath('api/hazard-history'), { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ location, locationKey, hazards }), }); + + if (!response.ok) { + throw new Error(`Hazard history sync failed with status ${response.status}`); + } + + window.dispatchEvent(new CustomEvent('hazard-history-updated')); } catch (error) { // Silently fail - hazard history is non-critical if (debugFlag('verbose-failures')) { diff --git a/src/hazard-history-worker.mjs b/src/hazard-history-worker.mjs new file mode 100644 index 0000000..a391b23 --- /dev/null +++ b/src/hazard-history-worker.mjs @@ -0,0 +1,187 @@ +import deriveHazards from '../server/scripts/modules/utils/derived-hazards.mjs'; +import { + getOngoingHazards, + markHazardEndedById, + touchHazardStillOngoing, +} from './hazard-history.mjs'; +import { buildWeatherParametersForLocation, parseLocationKey } from './weather-parameters.mjs'; + +const HAZARD_HISTORY_CHECK_INTERVAL_MS = 10 * 60 * 1000; +const HAZARD_HISTORY_CHECK_CONCURRENCY = 3; +const USER_AGENT = 'WeatherStar 4000+: Linhanced; marky611@gmail.com'; + +let hazardHistoryWorkerHandle = null; +let isHazardHistoryWorkerRunning = false; + +const buildHazardIdentity = (hazardType, source) => `${hazardType}::${source}`; + +const groupHazardsByLocationKey = (rows) => rows.reduce((groups, row) => { + const existing = groups.get(row.locationKey) ?? []; + existing.push(row); + groups.set(row.locationKey, existing); + return groups; +}, new Map()); + +const fetchJson = async (url) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': USER_AGENT, + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${url}`); + } + + return await response.json(); + } finally { + clearTimeout(timeoutId); + } +}; + +const mapNoaaAlertsToActiveHazards = (alertsResponse) => (alertsResponse?.features ?? []).map((feature) => ({ + hazardType: feature.properties?.event || 'Unknown', + latestHazardId: feature.id || null, + severity: feature.properties?.severity || null, + source: 'noaa', +})); + +const mapDerivedHazardsToActiveHazards = (derivedHazards) => (derivedHazards ?? []).map((hazard) => ({ + hazardType: hazard.properties?.event || 'Unknown', + latestHazardId: hazard.id || null, + severity: hazard.properties?.severity || null, + source: 'derived', +})); + +const fetchActiveNoaaHazardsForLocation = async ({ latitude, longitude }) => { + const url = new URL('https://api.weather.gov/alerts/active'); + url.searchParams.set('point', `${latitude},${longitude}`); + url.searchParams.set('status', 'actual'); + const alerts = await fetchJson(url.toString()); + return mapNoaaAlertsToActiveHazards(alerts); +}; + +const fetchActiveDerivedHazardsForLocation = async ({ locationKey, locationLabel }) => { + const weatherParameters = await buildWeatherParametersForLocation({ locationKey, locationLabel }); + return mapDerivedHazardsToActiveHazards(deriveHazards(weatherParameters)); +}; + +const runWithConcurrency = async (items, limit, worker) => { + const queue = [...items]; + const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => { + while (queue.length > 0) { + const next = queue.shift(); + if (!next) return; + await worker(next); + } + }); + await Promise.all(runners); +}; + +const reconcileHazardsForLocation = async ({ locationKey, rows }) => { + const coordinates = parseLocationKey(locationKey); + if (!coordinates) { + throw new Error(`Invalid location key '${locationKey}' in hazard history worker`); + } + + const locationLabel = rows[0]?.locationLabel || 'Unknown'; + const summary = { + refreshed: 0, + ended: 0, + }; + + const [activeNoaaHazards, activeDerivedHazards] = await Promise.all([ + rows.some((row) => row.source === 'noaa') + ? fetchActiveNoaaHazardsForLocation({ latitude: coordinates.lat, longitude: coordinates.lon }) + : Promise.resolve([]), + rows.some((row) => row.source === 'derived') + ? fetchActiveDerivedHazardsForLocation({ locationKey, locationLabel }) + : Promise.resolve([]), + ]); + + const activeHazards = [...activeNoaaHazards, ...activeDerivedHazards]; + const activeHazardMap = new Map(activeHazards.map((hazard) => [buildHazardIdentity(hazard.hazardType, hazard.source), hazard])); + + for (const row of rows) { + const activeHazard = activeHazardMap.get(buildHazardIdentity(row.hazardType, row.source)); + if (activeHazard) { + await touchHazardStillOngoing({ + id: row.id, + severity: activeHazard.severity, + latestHazardId: activeHazard.latestHazardId, + }); + summary.refreshed += 1; + } else { + await markHazardEndedById(row.id); + summary.ended += 1; + } + } + + return summary; +}; + +const runHazardHistoryCheckOnce = async () => { + if (isHazardHistoryWorkerRunning) return; + isHazardHistoryWorkerRunning = true; + + const totals = { + locationsChecked: 0, + refreshed: 0, + ended: 0, + failures: 0, + }; + + try { + const ongoingRows = await getOngoingHazards(); + const grouped = [...groupHazardsByLocationKey(ongoingRows).entries()].map(([locationKey, rows]) => ({ locationKey, rows })); + + await runWithConcurrency(grouped, HAZARD_HISTORY_CHECK_CONCURRENCY, async (group) => { + try { + const result = await reconcileHazardsForLocation(group); + totals.locationsChecked += 1; + totals.refreshed += result.refreshed; + totals.ended += result.ended; + } catch (error) { + totals.locationsChecked += 1; + totals.failures += 1; + console.warn(`Hazard worker location check failed for ${group.locationKey}: ${error.message}`); + } + }); + + console.log(`Hazard worker: checked ${totals.locationsChecked} locations, refreshed ${totals.refreshed} hazards, ended ${totals.ended} hazards, ${totals.failures} failure${totals.failures === 1 ? '' : 's'}`); + } finally { + isHazardHistoryWorkerRunning = false; + } +}; + +const startHazardHistoryWorker = () => { + if (hazardHistoryWorkerHandle) return; + runHazardHistoryCheckOnce().catch((error) => { + console.warn(`Hazard worker initial run failed: ${error.message}`); + }); + hazardHistoryWorkerHandle = setInterval(() => { + runHazardHistoryCheckOnce().catch((error) => { + console.warn(`Hazard worker run failed: ${error.message}`); + }); + }, HAZARD_HISTORY_CHECK_INTERVAL_MS); + if (typeof hazardHistoryWorkerHandle.unref === 'function') { + hazardHistoryWorkerHandle.unref(); + } +}; + +const stopHazardHistoryWorker = () => { + if (!hazardHistoryWorkerHandle) return; + clearInterval(hazardHistoryWorkerHandle); + hazardHistoryWorkerHandle = null; +}; + +export { + runHazardHistoryCheckOnce, + startHazardHistoryWorker, + stopHazardHistoryWorker, +}; diff --git a/src/hazard-history.mjs b/src/hazard-history.mjs index c2221cd..6e8de2a 100644 --- a/src/hazard-history.mjs +++ b/src/hazard-history.mjs @@ -133,6 +133,54 @@ const getHistory = async () => { return rows.map(mapRowToHistoryEntry); }; +const getOngoingHazards = async () => { + const [rows] = await getPool().query( + `SELECT + id, + location_label, + location_key, + hazard_type, + source, + severity, + latest_hazard_id + FROM hazard_history + WHERE ongoing = 1 + ORDER BY last_seen_at DESC`, + ); + + return rows.map((row) => ({ + id: row.id, + locationLabel: row.location_label, + locationKey: row.location_key, + hazardType: row.hazard_type, + source: row.source, + severity: row.severity, + latestHazardId: row.latest_hazard_id, + })); +}; + +const touchHazardStillOngoing = async ({ id, severity = null, latestHazardId = null }) => { + await getPool().execute( + `UPDATE hazard_history + SET severity = COALESCE(?, severity), + latest_hazard_id = COALESCE(?, latest_hazard_id), + last_seen_at = UTC_TIMESTAMP(), + ongoing = 1 + WHERE id = ?`, + [severity, latestHazardId, id], + ); +}; + +const markHazardEndedById = async (id) => { + await getPool().execute( + `UPDATE hazard_history + SET ongoing = 0, + last_seen_at = UTC_TIMESTAMP() + WHERE id = ?`, + [id], + ); +}; + const updateHistory = async (payload) => { const { location, locationKey, hazards = [] } = payload; const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source); @@ -242,6 +290,9 @@ const updateHistory = async (payload) => { export { formatLocation, getHistory, + getOngoingHazards, MAX_HISTORY_ENTRIES, + markHazardEndedById, + touchHazardStillOngoing, updateHistory, }; diff --git a/src/weather-parameters.mjs b/src/weather-parameters.mjs new file mode 100644 index 0000000..4175951 --- /dev/null +++ b/src/weather-parameters.mjs @@ -0,0 +1,86 @@ +import { aggregateWeatherForecastData } from '../server/scripts/modules/utils/weather.mjs'; + +const REQUEST_TIMEOUT_MS = 10000; +const USER_AGENT = 'WeatherStar 4000+: Linhanced; marky611@gmail.com'; +const OPEN_METEO_FORECAST_PARAMETERS = [ + 'daily=temperature_2m_max,temperature_2m_min,uv_index_max', + 'hourly=temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation_probability,precipitation,rain,showers,snowfall,snow_depth,weather_code,pressure_msl,surface_pressure,cloud_cover,visibility,uv_index,is_day,sunshine_duration,wind_speed_10m,wind_direction_10m,wind_gusts_10m', + 'timezone=auto', + 'models=best_match', +].join('&'); + +const parseLocationKey = (locationKey) => { + if (typeof locationKey !== 'string') return null; + const [latText, lonText] = locationKey.split(','); + const lat = Number.parseFloat(latText); + const lon = Number.parseFloat(lonText); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { lat, lon }; +}; + +const splitLocationLabel = (locationLabel = '') => { + const [cityPart = 'Unknown', regionPart = ''] = locationLabel.split(',').map((part) => part.trim()); + const isUsStyleState = /^[A-Z]{2}$/.test(regionPart); + return { + city: cityPart || 'Unknown', + state: isUsStyleState ? regionPart : '', + country: isUsStyleState ? 'United States' : regionPart, + countryCode: isUsStyleState ? 'US' : '', + }; +}; + +const fetchJson = async (url) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': USER_AGENT, + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${url}`); + } + + return await response.json(); + } finally { + clearTimeout(timeoutId); + } +}; + +const buildWeatherParametersForLocation = async ({ locationKey, locationLabel }) => { + const coordinates = parseLocationKey(locationKey); + if (!coordinates) { + throw new Error(`Invalid location key '${locationKey}' for derived hazard refresh`); + } + + const { lat, lon } = coordinates; + const locationParts = splitLocationLabel(locationLabel); + const forecast = await fetchJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_FORECAST_PARAMETERS}`); + const aggregatedForecast = aggregateWeatherForecastData(forecast); + if (!aggregatedForecast) { + throw new Error(`Unable to aggregate forecast for ${locationKey}`); + } + + return { + latitude: lat, + longitude: lon, + city: locationParts.city, + state: locationParts.state, + country: locationParts.country, + countryCode: locationParts.countryCode, + timeZone: forecast?.timezone || 'UTC', + forecast: aggregatedForecast, + supportsNoaaAlerts: false, + primaryForecastSource: 'open-meteo', + }; +}; + +export { + buildWeatherParametersForLocation, + parseLocationKey, + splitLocationLabel, +};