diff --git a/gulp/publish-frontend.mjs b/gulp/publish-frontend.mjs index f8a9585..b65ec03 100644 --- a/gulp/publish-frontend.mjs +++ b/gulp/publish-frontend.mjs @@ -134,6 +134,7 @@ const compressHtml = async () => src(htmlSources) const otherFiles = [ 'server/robots.txt', 'server/manifest.json', + 'server/alert/**/*.mp3', 'server/music/**/*.mp3', ]; const copyOtherFiles = () => src(otherFiles, { base: 'server/', encoding: false }) diff --git a/server/alert/tone.mp3 b/server/alert/tone.mp3 new file mode 100644 index 0000000..790d2b4 Binary files /dev/null and b/server/alert/tone.mp3 differ diff --git a/server/scripts/modules/hazards.mjs b/server/scripts/modules/hazards.mjs index 575f167..174295c 100644 --- a/server/scripts/modules/hazards.mjs +++ b/server/scripts/modules/hazards.mjs @@ -1,7 +1,9 @@ // hourly forecast list import STATUS from './status.mjs'; +import { setAlertToneActive } from './media.mjs'; import { safeJson } from './utils/fetch.mjs'; +import deriveHazards from './utils/derived-hazards.mjs'; import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay } from './navigation.mjs'; import calculateScrollTiming from './utils/scroll-timing.mjs'; @@ -55,13 +57,6 @@ class Hazards extends WeatherDisplay { async getData(weatherParameters, refresh) { // super checks for enabled const superResult = super.getData(weatherParameters, refresh); - if (!this.weatherParameters?.supportsNoaaAlerts) { - this.data = []; - this.timing.totalScreens = 0; - this.getDataCallback(); - this.setStatus(STATUS.loaded); - return; - } // hazards performs a silent refresh, but does not fall back to a previous fetch if no data is available // this is intentional to ensure the latest alerts only are displayed. @@ -79,26 +74,31 @@ class Hazards extends WeatherDisplay { try { const previousSignature = this.alertSignature; - // get the forecast using centralized safe handling - const url = new URL('https://api.weather.gov/alerts/active'); - url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`); - url.searchParams.append('status', 'actual'); - const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); - - if (!alerts) { - if (debugFlag('verbose-failures')) { - console.warn('Active Alerts request failed; assuming no active alerts'); - } - this.data = []; + if (!this.weatherParameters?.supportsNoaaAlerts) { + this.data = deriveHazards(this.weatherParameters); } else { - const allUnsortedAlerts = alerts.features ?? []; - const unsortedAlerts = allUnsortedAlerts.slice(0, 5); - const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false); - const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event))); - const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate'))); - this.data = filteredAlerts; + // get the forecast using centralized safe handling + const url = new URL('https://api.weather.gov/alerts/active'); + url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`); + url.searchParams.append('status', 'actual'); + const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); + + if (!alerts) { + if (debugFlag('verbose-failures')) { + console.warn('Active Alerts request failed; assuming no active alerts'); + } + this.data = []; + } else { + const allUnsortedAlerts = alerts.features ?? []; + const unsortedAlerts = allUnsortedAlerts.slice(0, 5); + const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false); + const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event))); + const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate'))); + this.data = filteredAlerts; + } } this.alertSignature = getAlertSignature(this.data); + setAlertToneActive(this.data.length > 0); const alertsChanged = previousSignature !== this.alertSignature; if (alertsChanged) { this.viewedAlerts.clear(); @@ -129,6 +129,7 @@ class Hazards extends WeatherDisplay { } } catch (error) { console.error(`Unexpected Active Alerts error: ${error.message}`); + setAlertToneActive(false); if (this.isEnabled) this.setStatus(STATUS.failed); // return undefined to other subscribers this.getDataCallback(undefined); diff --git a/server/scripts/modules/media.mjs b/server/scripts/modules/media.mjs index e06c6b7..abf4155 100644 --- a/server/scripts/modules/media.mjs +++ b/server/scripts/modules/media.mjs @@ -1,13 +1,22 @@ import { text } from './utils/fetch.mjs'; import Setting from './utils/setting.mjs'; import { registerHiddenSetting } from './share.mjs'; +import { withBasePath } from './utils/base-path.mjs'; let playlist; let currentTrack = 0; let player; +let alertTonePlayer; let sliderTimeout = null; let volumeSlider = null; let volumeSliderInput = null; +let alertToneActive = false; +let alertTonePending = false; +let resumeMediaAfterAlertTone = false; +let audioUnlocked = false; +let alertToneTimeout = null; + +const ALERT_TONE_DURATION_MS = 30_000; const mediaPlaying = new Setting('mediaPlaying', { name: 'Media Playing', @@ -33,11 +42,26 @@ document.addEventListener('DOMContentLoaded', () => { // get the playlist getMedia(); + registerAudioUnlockHandlers(); // register the volume setting registerHiddenSetting(mediaVolume.elemId, mediaVolume); }); +const unlockAudio = () => { + if (audioUnlocked) return; + audioUnlocked = true; + if (alertToneActive && alertTonePending) { + startAlertTone(); + } +}; + +const registerAudioUnlockHandlers = () => { + ['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => { + document.addEventListener(eventName, unlockAudio, { passive: true, once: true }); + }); +}; + const scanMusicDirectory = async () => { const parseDirectory = async (path, prefix = '') => { const listing = await text(path); @@ -170,6 +194,7 @@ const startMedia = async () => { if (!player) { initializePlayer(); } else { + if (alertToneActive) return; try { await player.play(); setTrackName(playlist.availableFiles[currentTrack]); @@ -275,6 +300,73 @@ const initializePlayer = () => { volumeSliderInput.value = Math.round(mediaVolume.value * 100); }; +const initializeAlertTonePlayer = () => { + if (alertTonePlayer) return; + alertTonePlayer = new Audio(withBasePath('alert/tone.mp3')); + alertTonePlayer.type = 'audio/mpeg'; + alertTonePlayer.preload = 'auto'; + alertTonePlayer.addEventListener('ended', () => { + if (alertToneActive) { + alertTonePlayer.currentTime = 0; + alertTonePlayer.play().catch((e) => { + console.error('Couldn\'t continue alert tone'); + console.error(e); + }); + } + }); +}; + +const startAlertTone = async () => { + if (!audioUnlocked) { + alertTonePending = true; + return; + } + initializeAlertTonePlayer(); + try { + await alertTonePlayer.play(); + alertTonePending = false; + resumeMediaAfterAlertTone = mediaPlaying.value === true; + if (alertToneTimeout) clearTimeout(alertToneTimeout); + alertToneTimeout = setTimeout(() => { + if (alertToneActive) { + setAlertToneActive(false); + } + }, ALERT_TONE_DURATION_MS); + if (player && !player.paused) { + player.pause(); + } + } catch (e) { + console.error('Couldn\'t play alert tone'); + console.error(e); + } +}; + +const stopAlertTone = () => { + alertTonePending = false; + if (alertToneTimeout) { + clearTimeout(alertToneTimeout); + alertToneTimeout = null; + } + if (alertTonePlayer) { + alertTonePlayer.pause(); + alertTonePlayer.currentTime = 0; + } + if (resumeMediaAfterAlertTone && mediaPlaying.value === true) { + startMedia(); + } + resumeMediaAfterAlertTone = false; +}; + +const setAlertToneActive = (active) => { + if (active === alertToneActive) return; + alertToneActive = active; + if (alertToneActive) { + startAlertTone(); + return; + } + stopAlertTone(); +}; + const playerCanPlay = async () => { // check to make sure they user still wants music (protect against slow loading music) if (!mediaPlaying.value) return; @@ -304,6 +396,6 @@ const setTrackName = (fileName) => { }; export { - // eslint-disable-next-line import/prefer-default-export handleClick, + setAlertToneActive, }; diff --git a/server/scripts/modules/utils/derived-hazards.mjs b/server/scripts/modules/utils/derived-hazards.mjs new file mode 100644 index 0000000..6e1e73c --- /dev/null +++ b/server/scripts/modules/utils/derived-hazards.mjs @@ -0,0 +1,205 @@ +import { DateTime } from '../../vendor/auto/luxon.mjs'; + +const LOOKAHEAD_HOURS = 6; +const METERS_PER_MILE = 1609.344; +const KPH_PER_MPH = 1.609344; + +const SEVERITY_RANK = { + Extreme: 2, + Severe: 1, +}; + +const RULE_PRIORITY = { + thunderstorm: 5, + freezing: 4, + snow: 3, + rain: 2, + wind: 1, +}; + +const WEATHER_CODES = { + freezing: new Set([56, 57, 66, 67]), + snow: new Set([71, 73, 75, 77, 85, 86]), + thunderstorm: new Set([95, 96, 99]), + rain: new Set([51, 53, 55, 61, 63, 65, 80, 81, 82]), +}; + +const thresholds = { + lowVisibilitySevere: 5 * METERS_PER_MILE, + lowVisibilityExtreme: 2 * METERS_PER_MILE, + gustSevere: 20 * KPH_PER_MPH, + gustExtreme: 35 * KPH_PER_MPH, + highWindSevere: 40 * KPH_PER_MPH, + highWindExtreme: 55 * KPH_PER_MPH, + freezingTempC: 1, +}; + +const buildDerivedHazard = ({ id, severity, description, priority }) => ({ + id, + priority, + properties: { + event: 'Severe Weather Alert', + severity, + urgency: 'Expected', + description: `This is a derived local alert based on forecast conditions. ${description}`, + }, +}); + +const getUpcomingHours = (weatherParameters) => { + const zone = weatherParameters?.timeZone || 'UTC'; + const now = DateTime.now().setZone(zone); + const end = now.plus({ hours: LOOKAHEAD_HOURS }); + const allHours = Object.values(weatherParameters?.forecast ?? {}) + .flatMap((day) => day?.hours ?? []) + .map((hour) => ({ + ...hour, + forecastTime: DateTime.fromISO(hour.time, { zone }), + })) + .filter((hour) => hour.forecastTime.isValid) + .sort((a, b) => a.forecastTime.toMillis() - b.forecastTime.toMillis()); + + return allHours.filter((hour) => hour.forecastTime >= now && hour.forecastTime <= end); +}; + +const getWorstHour = (hours, evaluator) => hours.reduce((worst, hour) => { + const candidate = evaluator(hour); + if (!candidate) return worst; + if (!worst) return candidate; + if (SEVERITY_RANK[candidate.severity] > SEVERITY_RANK[worst.severity]) return candidate; + return worst; +}, null); + +const evaluateThunderstorm = (hours) => getWorstHour(hours, (hour) => { + const code = Number(hour.weather_code ?? 0); + if (!WEATHER_CODES.thunderstorm.has(code)) return null; + if (code === 96 || code === 99) { + return { + severity: 'Extreme', + description: 'Thunderstorms with hail are possible in the next several hours and may create dangerous outdoor conditions.', + }; + } + return { + severity: 'Severe', + description: 'Thunderstorms are possible in the next several hours and may create hazardous outdoor conditions.', + }; +}); + +const evaluateFreezing = (hours) => getWorstHour(hours, (hour) => { + const code = Number(hour.weather_code ?? 0); + if (!WEATHER_CODES.freezing.has(code)) return null; + const temperature = hour.temperature_2m ?? Number.POSITIVE_INFINITY; + if (temperature > thresholds.freezingTempC) return null; + const visibility = hour.visibility ?? Number.POSITIVE_INFINITY; + const gusts = hour.wind_gusts_10m ?? 0; + const isExtreme = visibility <= thresholds.lowVisibilityExtreme || gusts >= thresholds.gustExtreme; + return { + severity: isExtreme ? 'Extreme' : 'Severe', + description: isExtreme + ? 'Freezing precipitation with poor visibility or strong gusts is expected in the next several hours and may create dangerous travel conditions.' + : 'Freezing precipitation is expected in the next several hours and may create slippery travel conditions.', + }; +}); + +const evaluateSnow = (hours) => getWorstHour(hours, (hour) => { + const code = Number(hour.weather_code ?? 0); + if (!WEATHER_CODES.snow.has(code)) return null; + const visibility = hour.visibility ?? Number.POSITIVE_INFINITY; + const gusts = hour.wind_gusts_10m ?? 0; + if (visibility <= thresholds.lowVisibilitySevere && gusts >= thresholds.gustSevere) { + return { + severity: visibility <= thresholds.lowVisibilityExtreme ? 'Extreme' : 'Severe', + description: visibility <= thresholds.lowVisibilityExtreme + ? 'Snow, poor visibility, and gusty winds are expected in the next several hours and may create dangerous travel conditions.' + : 'Snow, reduced visibility, and gusty winds are expected in the next several hours and may create hazardous travel conditions.', + }; + } + return null; +}); + +const evaluateRain = (hours) => getWorstHour(hours, (hour) => { + const code = Number(hour.weather_code ?? 0); + const visibility = hour.visibility ?? Number.POSITIVE_INFINITY; + const gusts = hour.wind_gusts_10m ?? 0; + const hasRain = WEATHER_CODES.rain.has(code) || (hour.rain ?? 0) > 0 || (hour.showers ?? 0) > 0; + if (!hasRain) return null; + if (visibility <= thresholds.lowVisibilityExtreme && gusts >= thresholds.gustExtreme) { + return { + severity: 'Extreme', + description: 'Heavy rain, very low visibility, and strong gusts are expected in the next several hours and may create dangerous travel conditions.', + }; + } + if (visibility <= thresholds.lowVisibilitySevere && gusts >= thresholds.gustSevere) { + return { + severity: 'Severe', + description: 'Heavy rain, reduced visibility, and gusty winds are expected in the next several hours and may create hazardous travel conditions.', + }; + } + return null; +}); + +const evaluateWind = (hours) => getWorstHour(hours, (hour) => { + const gusts = hour.wind_gusts_10m ?? 0; + if (gusts >= thresholds.highWindExtreme) { + return { + severity: 'Extreme', + description: 'Very strong wind gusts are expected in the next several hours and may create dangerous conditions for travel and outdoor activity.', + }; + } + if (gusts >= thresholds.highWindSevere) { + return { + severity: 'Severe', + description: 'Strong wind gusts are expected in the next several hours and may create hazardous conditions for travel and outdoor activity.', + }; + } + return null; +}); + +const deriveHazards = (weatherParameters) => { + const upcomingHours = getUpcomingHours(weatherParameters); + if (upcomingHours.length === 0) return []; + const thunderstormCandidate = evaluateThunderstorm(upcomingHours); + const freezingCandidate = evaluateFreezing(upcomingHours); + const snowCandidate = evaluateSnow(upcomingHours); + const rainCandidate = evaluateRain(upcomingHours); + const windCandidate = evaluateWind(upcomingHours); + + const candidates = [ + thunderstormCandidate && buildDerivedHazard({ + id: 'derived-severe-weather-alert-thunderstorm', + priority: RULE_PRIORITY.thunderstorm, + ...thunderstormCandidate, + }), + freezingCandidate && buildDerivedHazard({ + id: 'derived-severe-weather-alert-freezing', + priority: RULE_PRIORITY.freezing, + ...freezingCandidate, + }), + snowCandidate && buildDerivedHazard({ + id: 'derived-severe-weather-alert-snow', + priority: RULE_PRIORITY.snow, + ...snowCandidate, + }), + rainCandidate && buildDerivedHazard({ + id: 'derived-severe-weather-alert-rain', + priority: RULE_PRIORITY.rain, + ...rainCandidate, + }), + windCandidate && buildDerivedHazard({ + id: 'derived-severe-weather-alert-wind', + priority: RULE_PRIORITY.wind, + ...windCandidate, + }), + ].filter(Boolean); + + if (candidates.length === 0) return []; + + candidates.sort((a, b) => { + const severityDiff = SEVERITY_RANK[b.properties.severity] - SEVERITY_RANK[a.properties.severity]; + if (severityDiff !== 0) return severityDiff; + return b.priority - a.priority; + }); + + return [candidates[0]]; +}; + +export default deriveHazards;