diff --git a/gulp/publish-frontend.mjs b/gulp/publish-frontend.mjs index b65ec03..f8a9585 100644 --- a/gulp/publish-frontend.mjs +++ b/gulp/publish-frontend.mjs @@ -134,7 +134,6 @@ 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 deleted file mode 100644 index 790d2b4..0000000 Binary files a/server/alert/tone.mp3 and /dev/null differ diff --git a/server/scripts/modules/hazards.mjs b/server/scripts/modules/hazards.mjs index 174295c..575f167 100644 --- a/server/scripts/modules/hazards.mjs +++ b/server/scripts/modules/hazards.mjs @@ -1,9 +1,7 @@ // 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'; @@ -57,6 +55,13 @@ 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. @@ -74,31 +79,26 @@ class Hazards extends WeatherDisplay { try { const previousSignature = this.alertSignature; - if (!this.weatherParameters?.supportsNoaaAlerts) { - this.data = deriveHazards(this.weatherParameters); - } else { - // 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() }); + // 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; + 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,7 +129,6 @@ 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 abf4155..e06c6b7 100644 --- a/server/scripts/modules/media.mjs +++ b/server/scripts/modules/media.mjs @@ -1,22 +1,13 @@ 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', @@ -42,26 +33,11 @@ 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); @@ -194,7 +170,6 @@ const startMedia = async () => { if (!player) { initializePlayer(); } else { - if (alertToneActive) return; try { await player.play(); setTrackName(playlist.availableFiles[currentTrack]); @@ -300,73 +275,6 @@ 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; @@ -396,6 +304,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 deleted file mode 100644 index 6e1e73c..0000000 --- a/server/scripts/modules/utils/derived-hazards.mjs +++ /dev/null @@ -1,205 +0,0 @@ -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;