diff --git a/cspell.config.json b/cspell.config.json index 4ee41918b3..2bcac3aed6 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -16,6 +16,8 @@ "Autorestart", "avghumidity", "avgtemp", + "bbox", + "BBOX", "beada", "Behaviour", "Beschreibung", @@ -113,8 +115,10 @@ "fewieden", "fixuppm", "flopp", + "fmisid", "fontawesome", "fontface", + "fmi", "forecastday", "forecastweather", "fortawesome", @@ -131,6 +135,8 @@ "ghsas", "grenagit", "Halfclear", + "HARMONIE", + "harmonie", "heavyrain", "heavyrainandthunder", "heavyrainshowers", @@ -159,10 +165,12 @@ "jsonlint", "jupadin", "kaennchenstruggle", + "Kaisaniemi", "Kalenderwoche", "kenzal", "Keyport", "khassel", + "kilometres", "Kingdon", "kioskmode", "klaernie", @@ -179,13 +187,17 @@ "Landis", "larryare", "Lastberechnung", + "latlon", + "lentoasema", "letsencrypt", "libgpiod", "Lightspeed", "loadingcircle", + "locationcode", "locationforecast", "lockstring", "logg", + "LOOKBACK", "lstrip", "Luciella", "luxon", @@ -232,6 +244,7 @@ "odroid", "oemel", "oldconfig", + "omso", "onecall", "onevent", "openmeteo", @@ -287,10 +300,12 @@ "socketio", "spectron", "Starinvest", + "stationcode", "stationid", "STEADMAN", "sthuber", "Stieber", + "storedquery", "strinner", "sunaction", "suncalc", @@ -308,6 +323,7 @@ "thomasrockhu", "thumbslider", "timeformat", + "timevaluepair", "titlereplacestr", "titlesearchstr", "TOCTOU", @@ -325,6 +341,8 @@ "updatenotification", "uxdt", "Vaice", + "Vantaa", + "Vantaan", "VCALENDAR", "veeck", "verjaardag", @@ -357,6 +375,7 @@ "Wsymb", "xhvw", "xlarge", + "xlink", "xmark", "xrandr", "xsmall", diff --git a/defaultmodules/weather/providers/fmi.js b/defaultmodules/weather/providers/fmi.js new file mode 100644 index 0000000000..21bc46cf0f --- /dev/null +++ b/defaultmodules/weather/providers/fmi.js @@ -0,0 +1,670 @@ +const Log = require("logger"); +const { validateCoordinates } = require("../provider-utils"); +const WeatherProvider = require("../weatherprovider"); + +const FMI_WFS_URL = "https://opendata.fmi.fi/wfs"; +const OBSERVATION_QUERY = "fmi::observations::weather::timevaluepair"; +const HTTPFetcher = require("#http_fetcher"); + +const OBSERVATION_PARAMETERS = [ + "t2m", + "rh", + "ws_10min", + "wd_10min", + "wg_10min", + "p_sea", + "r_1h" +].join(","); + +const REQUIRED_OBSERVATION_PARAMETERS = [ + "t2m", + "rh", + "ws_10min", + "wd_10min", + "p_sea" +]; + +const OBSERVATION_LOOKBACK_MINUTES = 30; +const OBSERVATION_BBOX_RADIUS = 0.25; + +const HARMONIE_FORECAST_QUERY = "fmi::forecast::harmonie::surface::point::timevaluepair"; + +const HARMONIE_FORECAST_PARAMETERS = [ + "Temperature", + "Humidity", + "WindSpeedMS", + "WindDirection", + "WindGust", + "Pressure", + "Precipitation1h", + "WeatherSymbol3" +].join(","); + +const EARTH_RADIUS_KM = 6371; + +const FMI_TIME_ZONE = "Europe/Helsinki"; + +/** + * Convert degrees to radians. + * @param {number} degrees Angle in degrees. + * @returns {number} Angle in radians. + */ +const toRadians = (degrees) => degrees * (Math.PI / 180); + +/** + * Calculate the great-circle distance between two coordinates. + * @param {number} lat1 First latitude. + * @param {number} lon1 First longitude. + * @param {number} lat2 Second latitude. + * @param {number} lon2 Second longitude. + * @returns {number} Distance in kilometres. + */ +const calculateDistance = (lat1, lon1, lat2, lon2) => { + const latDifference = toRadians(lat2 - lat1); + const lonDifference = toRadians(lon2 - lon1); + + const a + = Math.sin(latDifference / 2) ** 2 + + Math.cos(toRadians(lat1)) + * Math.cos(toRadians(lat2)) + * Math.sin(lonDifference / 2) ** 2; + + return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}; + +const WEATHER_SYMBOL_MAP = { + 1: "day-sunny", + 2: "day-cloudy", + 3: "cloudy", + 21: "showers", + 22: "showers", + 23: "showers", + 31: "rain", + 32: "rain", + 33: "rain", + 41: "snow", + 42: "snow", + 43: "snow", + 51: "snow", + 52: "snow", + 53: "snow", + 61: "thunderstorm", + 62: "thunderstorm", + 63: "thunderstorm", + 64: "thunderstorm", + 71: "sleet", + 72: "sleet", + 73: "sleet", + 81: "sleet", + 82: "sleet", + 83: "sleet", + 91: "fog", + 92: "fog" +}; + +/** + * Return date and hour components for an FMI timestamp in Finnish local time. + * @param {string|Date} value Forecast timestamp. + * @returns {{dateKey: string, hour: number}} Local date key and hour. + */ +const getFinnishLocalTime = (value) => { + const date = new Date(value); + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: FMI_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hourCycle: "h23" + }).formatToParts(date); + + const getPart = (type) => parts.find((part) => part.type === type)?.value; + + return { + dateKey: `${getPart("year")}-${getPart("month")}-${getPart("day")}`, + hour: Number(getPart("hour")) + }; +}; + +/** + * Server-side weather provider for the Finnish Meteorological Institute (FMI). + * Uses FMI Open Data for weather observations and forecasts. + */ +class FMIProvider extends WeatherProvider { + constructor (config) { + super(); + + this.config = { + lat: 0, + lon: 0, + type: "current", + updateInterval: 10 * 60 * 1000, + ...config + }; + } + + initialize () { + try { + validateCoordinates(this.config); + + if (this.config.type === "current") { + this.#initializeObservationFetcher(); + } else if ( + this.config.type === "hourly" + || this.config.type === "forecast" + || this.config.type === "daily" + ) { + this.#initializeForecastFetcher(); + } + } catch (error) { + Log.error("[fmi] Initialization failed:", error); + + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + } + + #initializeObservationFetcher () { + if (this.config.type !== "current") { + return; + } + + this.fetcher = new HTTPFetcher(() => this.buildObservationUrl(), { + reloadInterval: this.config.updateInterval, + logContext: "weatherprovider.fmi" + }); + + this.fetcher.on("response", async (response) => { + if (response.status === 304) { + return; + } + + try { + const xml = await response.text(); + const observations = this.parseObservationXml(xml); + const selectedStation = this.selectNearestObservationStation(observations); + + if (!selectedStation) { + throw new Error("No suitable FMI observation station found"); + } + + const weatherData = this.generateCurrentWeather(selectedStation); + + if (this.onDataCallback) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[fmi] Failed to process observation data:", error); + + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + }); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + #initializeForecastFetcher () { + this.fetcher = new HTTPFetcher(() => this.buildForecastUrl(), { + reloadInterval: this.config.updateInterval, + logContext: "weatherprovider.fmi" + }); + + this.fetcher.on("response", async (response) => { + if (response.status === 304) { + return; + } + + try { + const xml = await response.text(); + const forecasts = this.parseForecastXml(xml); + const weatherData + = this.config.type === "hourly" + ? this.generateHourlyForecast(forecasts) + : this.generateDailyForecast(forecasts); + + if (this.onDataCallback) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[fmi] Failed to process forecast data:", error); + + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + }); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + generateCurrentWeather (selectedStation) { + const { values } = selectedStation; + + const dates = Object.values(values) + .map((measurement) => measurement.time) + .filter(Boolean) + .map((time) => new Date(time)); + + const current = { + date: dates.length > 0 + ? new Date(Math.max(...dates.map((date) => date.getTime()))) + : new Date(), + temperature: values.t2m.value, + humidity: values.rh.value, + windSpeed: values.ws_10min.value, + windFromDirection: values.wd_10min.value, + pressure: values.p_sea.value + }; + + if (values.wg_10min) { + current.windGust = values.wg_10min.value; + } + + if (values.r_1h) { + current.precipitationAmount = values.r_1h.value; + } + + this.locationName = selectedStation.station.name; + + return current; + } + + /** + * Build the FMI WFS URL used for current weather observations. + * The bounding box allows FMI to return nearby observation stations so the + * provider can select the station closest to the configured coordinates. + * @param {Date} [now] Current time used to build the observation window. + * @returns {string} FMI WFS observation request URL. + */ + buildObservationUrl (now = new Date()) { + const startTime = new Date(now.getTime() - OBSERVATION_LOOKBACK_MINUTES * 60 * 1000); + + const minLon = this.config.lon - OBSERVATION_BBOX_RADIUS; + const minLat = this.config.lat - OBSERVATION_BBOX_RADIUS; + const maxLon = this.config.lon + OBSERVATION_BBOX_RADIUS; + const maxLat = this.config.lat + OBSERVATION_BBOX_RADIUS; + + const url = new URL(FMI_WFS_URL); + + url.search = new URLSearchParams({ + service: "WFS", + version: "2.0.0", + request: "getFeature", + storedquery_id: OBSERVATION_QUERY, + bbox: `${minLon},${minLat},${maxLon},${maxLat}`, + starttime: startTime.toISOString(), + endtime: now.toISOString(), + parameters: OBSERVATION_PARAMETERS + }).toString(); + + return url.toString(); + } + + /** + * Build the FMI WFS URL used for HARMONIE point forecasts. + * @returns {string} FMI WFS forecast request URL. + */ + buildForecastUrl () { + const url = new URL(FMI_WFS_URL); + + url.search = new URLSearchParams({ + service: "WFS", + version: "2.0.0", + request: "getFeature", + storedquery_id: HARMONIE_FORECAST_QUERY, + latlon: `${this.config.lat},${this.config.lon}`, + parameters: HARMONIE_FORECAST_PARAMETERS + }).toString(); + + return url.toString(); + } + + + /** + * Parse FMI WFS point time-series observations. + * @param {string} xml FMI WFS response body. + * @returns {object[]} Parsed observations. + */ + parseObservationXml (xml) { + const observations = []; + const observationPattern = /]*>(.*?)<\/omso:PointTimeSeriesObservation>/gs; + + for (const match of xml.matchAll(observationPattern)) { + const observation = this.#parseObservation(match[1]); + + if (observation) { + observations.push(observation); + } + } + + return observations; + } + + parseForecastXml (xml) { + const forecastsByTime = new Map(); + const observationPattern + = /]*>(.*?)<\/omso:PointTimeSeriesObservation>/gs; + + for (const match of xml.matchAll(observationPattern)) { + const observation = this.#parseForecastObservation(match[1]); + + if (!observation) { + continue; + } + + for (const measurement of observation.measurements) { + if (!forecastsByTime.has(measurement.time)) { + forecastsByTime.set(measurement.time, { + time: measurement.time + }); + } + + forecastsByTime.get(measurement.time)[observation.parameter] = measurement.value; + } + } + + return [...forecastsByTime.values()].sort( + (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime() + ); + } + + generateHourlyForecast (forecasts) { + return forecasts.map((forecast) => ({ + date: new Date(forecast.time), + temperature: forecast.Temperature, + humidity: forecast.Humidity, + windSpeed: forecast.WindSpeedMS, + windFromDirection: forecast.WindDirection, + windGust: forecast.WindGust, + pressure: forecast.Pressure, + precipitationAmount: forecast.Precipitation1h, + weatherType: WEATHER_SYMBOL_MAP[forecast.WeatherSymbol3] + })); + } + + generateDailyForecast (forecasts) { + const dayMap = new Map(); + + for (const forecast of forecasts) { + const { dateKey, hour } = getFinnishLocalTime(forecast.time); + + if (!dayMap.has(dateKey)) { + dayMap.set(dateKey, { + date: new Date(forecast.time), + temperatures: [], + precipitationAmount: 0, + weatherType: WEATHER_SYMBOL_MAP[forecast.WeatherSymbol3] + }); + } + + const day = dayMap.get(dateKey); + + if (Number.isFinite(forecast.Temperature)) { + day.temperatures.push(forecast.Temperature); + } + + if (Number.isFinite(forecast.Precipitation1h)) { + day.precipitationAmount += forecast.Precipitation1h; + } + + if (hour >= 8 && hour <= 17 && Number.isFinite(forecast.WeatherSymbol3)) { + day.weatherType = WEATHER_SYMBOL_MAP[forecast.WeatherSymbol3]; + } + } + + return Array.from(dayMap.values()) + .filter((day) => day.temperatures.length > 0) + .map((day) => ({ + date: day.date, + minTemperature: Math.min(...day.temperatures), + maxTemperature: Math.max(...day.temperatures), + weatherType: day.weatherType, + precipitationAmount: day.precipitationAmount + })); + } + + /** + * Group parsed observations by station and select the station nearest to + * the configured coordinates. + * @param {object[]} observations Parsed FMI observations. + * @returns {object|null} Normalized data for the nearest station. + */ + selectNearestObservationStation (observations) { + const stations = new Map(); + + for (const observation of observations) { + const { station, parameter, measurements } = observation; + + if (!stations.has(station.id)) { + stations.set(station.id, { + station: { + ...station, + distance: calculateDistance( + this.config.lat, + this.config.lon, + station.lat, + station.lon + ) + }, + values: {} + }); + } + + const latestMeasurement = this.#getLatestMeasurement(measurements); + + if (latestMeasurement) { + stations.get(station.id).values[parameter] = latestMeasurement; + } + } + + if (stations.size === 0) { + return null; + } + + const completeStations = [...stations.values()].filter((station) => REQUIRED_OBSERVATION_PARAMETERS.every((parameter) => Object.hasOwn(station.values, parameter))); + + if (completeStations.length === 0) { + return null; + } + + return completeStations.reduce((nearest, station) => (station.station.distance < nearest.station.distance ? station : nearest)); + } + + /** + * Select the newest valid measurement from a parameter time series. + * @param {object[]} measurements Parsed measurements. + * @returns {object|null} Latest measurement. + */ + #getLatestMeasurement (measurements) { + let latest = null; + + for (const measurement of measurements) { + const timestamp = Date.parse(measurement.time); + + if (!Number.isFinite(timestamp) || !Number.isFinite(measurement.value)) { + continue; + } + + if (!latest || timestamp > latest.timestamp) { + latest = { + time: measurement.time, + value: measurement.value, + timestamp + }; + } + } + + if (!latest) { + return null; + } + + return { + time: latest.time, + value: latest.value + }; + } + + /** + * Parse one FMI point time-series observation. + * @param {string} xml Observation XML. + * @returns {object|null} Parsed observation, or null when required metadata is missing. + */ + #parseObservation (xml) { + const parameter = this.#extractAttributeQueryParameter(xml, "observedProperty", "param"); + const stationId = this.#extract( + xml, + /]*codeSpace="http:\/\/xml\.fmi\.fi\/namespace\/stationcode\/fmisid"[^>]*>([^<]+)<\/gml:identifier>/ + ); + const stationName = this.#extract( + xml, + /]*codeSpace="http:\/\/xml\.fmi\.fi\/namespace\/locationcode\/name"[^>]*>([^<]+)<\/gml:name>/ + ); + const position = this.#extract(xml, /]*>([^<]+)<\/gml:pos>/); + + if (!parameter || !stationId || !stationName || !position) { + return null; + } + + const coordinates = position + .trim() + .split(/\s+/) + .map(Number); + + if (coordinates.length !== 2 || coordinates.some((coordinate) => !Number.isFinite(coordinate))) { + return null; + } + + const measurements = []; + const measurementPattern = /(.*?)<\/wml2:MeasurementTVP>/gs; + + for (const match of xml.matchAll(measurementPattern)) { + const time = this.#extract(match[1], /([^<]+)<\/wml2:time>/); + const value = this.#extract(match[1], /([^<]+)<\/wml2:value>/); + + if (!time || value === null) { + continue; + } + + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) { + continue; + } + + measurements.push({ + time, + value: numericValue + }); + } + + return { + parameter, + station: { + id: Number(stationId), + name: stationName, + lat: coordinates[0], + lon: coordinates[1] + }, + measurements + }; + } + + /** + * Parse one FMI forecast point time-series observation. + * Forecast data does not contain observation-station metadata. + * @param {string} xml Forecast observation XML. + * @returns {object|null} Parsed forecast observation. + */ + #parseForecastObservation (xml) { + const parameter = this.#extractAttributeQueryParameter(xml, "observedProperty", "param"); + + if (!parameter) { + return null; + } + + const measurements = []; + const measurementPattern = /(.*?)<\/wml2:MeasurementTVP>/gs; + + for (const match of xml.matchAll(measurementPattern)) { + const time = this.#extract(match[1], /([^<]+)<\/wml2:time>/); + const value = this.#extract(match[1], /([^<]+)<\/wml2:value>/); + + if (!time || value === null) { + continue; + } + + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) { + continue; + } + + measurements.push({ + time, + value: numericValue + }); + } + + return { + parameter, + measurements + }; + } + + /** + * Extract text captured by a regular expression. + * @param {string} value Source string. + * @param {RegExp} pattern Pattern containing one capture group. + * @returns {string|null} Captured value. + */ + #extract (value, pattern) { + const match = value.match(pattern); + return match ? match[1].trim() : null; + } + + /** + * Extract a query parameter from an FMI xlink:href attribute. + * @param {string} xml Observation XML. + * @param {string} elementName Element containing the xlink:href attribute. + * @param {string} parameterName Query parameter to extract. + * @returns {string|null} Query parameter value. + */ + #extractAttributeQueryParameter (xml, elementName, parameterName) { + const pattern = new RegExp(`]*xlink:href="([^"]+)"`); + const href = this.#extract(xml, pattern); + + if (!href) { + return null; + } + + const decodedHref = href.replaceAll("&", "&"); + + try { + return new URL(decodedHref).searchParams.get(parameterName); + } catch { + return null; + } + } +} + +module.exports = FMIProvider; diff --git a/js/http_fetcher.js b/js/http_fetcher.js index 747725c53c..d9a8164b4a 100644 --- a/js/http_fetcher.js +++ b/js/http_fetcher.js @@ -61,7 +61,7 @@ class HTTPFetcher extends EventEmitter { /** * Creates a new HTTPFetcher instance - * @param {string} url - The URL to fetch + * @param {string|(() => string)} url - The URL to fetch, or a function that returns the URL * @param {object} options - Configuration options * @param {number} [options.reloadInterval] - Time in ms between fetches (default: 5 min) * @param {object} [options.auth] - Authentication options @@ -190,25 +190,36 @@ class HTTPFetcher extends EventEmitter { return null; } + /** + * Resolves the URL for the current request. + * Supports both static URL strings and functions that generate a URL dynamically. + * @returns {string} URL to use for the request. + */ + #getUrl () { + return typeof this.url === "function" ? this.url() : this.url; + } + /** * Returns a shortened version of the URL for log messages. + * @param {string} url - Resolved URL used for the request * @returns {string} Shortened URL */ - #shortenUrl () { + #shortenUrl (url) { try { - const urlObj = new URL(this.url); + const urlObj = new URL(url); return `${urlObj.origin}${urlObj.pathname}${urlObj.search.length > 50 ? "?..." : urlObj.search}`; } catch { - return this.url; + return url; } } /** * Determines the retry delay for a non-ok response * @param {Response} response - The fetch Response object + * @param {string} url - Resolved URL used for the request * @returns {{delay: number, errorInfo: object}} Computed retry delay and error info */ - #getDelayForResponse (response) { + #getDelayForResponse (response, url) { const { status } = response; let delay = this.reloadInterval; let message; @@ -218,14 +229,14 @@ class HTTPFetcher extends EventEmitter { errorType = "AUTH_FAILURE"; delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES); message = `Authentication failed (${status}). Check your API key. Waiting ${Math.round(delay / 60000)} minutes before retry.`; - Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl(url)} - ${message}`); } else if (status === 429) { errorType = "RATE_LIMITED"; const retryAfter = response.headers.get("retry-after"); const parsed = retryAfter ? this.#parseRetryAfter(retryAfter) : null; delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); message = `Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`; - Log.warn(`${this.logContext}${this.#shortenUrl()} - ${message}`); + Log.warn(`${this.logContext}${this.#shortenUrl(url)} - ${message}`); } else if (status >= 500) { errorType = "SERVER_ERROR"; this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries); @@ -238,20 +249,20 @@ class HTTPFetcher extends EventEmitter { }); message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 1000)}s.`; } - Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl(url)} - ${message}`); } else if (status >= 400) { errorType = "CLIENT_ERROR"; delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); message = `Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`; - Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl(url)} - ${message}`); } else { message = `Unexpected HTTP status ${status}.`; - Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl(url)} - ${message}`); } return { delay, - errorInfo: this.#createErrorInfo(message, status, errorType, delay) + errorInfo: this.#createErrorInfo(message, status, errorType, delay, null, url) }; } @@ -262,9 +273,10 @@ class HTTPFetcher extends EventEmitter { * @param {string} errorType - Error type: AUTH_FAILURE, RATE_LIMITED, SERVER_ERROR, CLIENT_ERROR, NETWORK_ERROR * @param {number} retryAfter - Delay until next retry in ms * @param {Error} [originalError] - The original error if any + * @param {string} [url] - Resolved URL used for the request * @returns {object} Error info object with translationKey for direct use */ - #createErrorInfo (message, status, errorType, retryAfter, originalError = null) { + #createErrorInfo (message, status, errorType, retryAfter, originalError = null, url = this.url) { return { message, status, @@ -272,7 +284,7 @@ class HTTPFetcher extends EventEmitter { translationKey: ERROR_TYPE_TO_TRANSLATION[errorType] || "MODULE_ERROR_UNSPECIFIED", retryAfter, retryCount: errorType === "NETWORK_ERROR" ? this.networkErrorCount : this.serverErrorCount, - url: this.url, + url, originalError }; } @@ -288,6 +300,7 @@ class HTTPFetcher extends EventEmitter { let nextDelay = this.reloadInterval; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); + const url = this.#getUrl(); try { const requestOptions = this.getRequestOptions(); @@ -295,7 +308,8 @@ class HTTPFetcher extends EventEmitter { // because Node's global fetch and npm undici@8 Agents are incompatible. // For regular requests, use globalThis.fetch so MSW and other interceptors work. const fetchFn = requestOptions.dispatcher ? undiciFetch : globalThis.fetch; - const response = await fetchFn(this.url, { + + const response = await fetchFn(url, { ...requestOptions, signal: controller.signal }); @@ -314,7 +328,7 @@ class HTTPFetcher extends EventEmitter { */ this.emit("response", response); } else { - const { delay, errorInfo } = this.#getDelayForResponse(response); + const { delay, errorInfo } = this.#getDelayForResponse(response, url); nextDelay = delay; this.emit("error", errorInfo); } @@ -327,12 +341,12 @@ class HTTPFetcher extends EventEmitter { if (exhausted) { nextDelay = this.reloadInterval; - Log.error(`${this.logContext}${this.#shortenUrl()} - ${message} Max retries reached, retrying at configured interval (${Math.round(nextDelay / 1000)}s).`); + Log.error(`${this.logContext}${this.#shortenUrl(url)} - ${message} Max retries reached, retrying at configured interval (${Math.round(nextDelay / 1000)}s).`); } else { nextDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, { maxDelay: this.reloadInterval }); - const retryMsg = `${this.logContext}${this.#shortenUrl()} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`; + const retryMsg = `${this.logContext}${this.#shortenUrl(url)} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`; if (this.networkErrorCount <= 2) { Log.warn(retryMsg); } else { @@ -345,7 +359,8 @@ class HTTPFetcher extends EventEmitter { null, "NETWORK_ERROR", nextDelay, - error + error, + url ); this.emit("error", errorInfo); } finally { diff --git a/js/releasenotes.js b/js/releasenotes.js index 258c1d7fb1..ea10f11597 100644 --- a/js/releasenotes.js +++ b/js/releasenotes.js @@ -52,7 +52,7 @@ const createReleaseNotes = async () => { const nodeVersion = JSON.parse(fs.readFileSync("package.json")).engines.node; // Search strings - const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"]; + const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "fmi", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"]; // Map search strings to categories const getFirstLabel = (text) => { @@ -74,6 +74,7 @@ const createReleaseNotes = async () => { res = "dependencies"; break; case "envcanada": + case "fmi": case "openmeteo": case "openweathermap": case "smhi": diff --git a/tests/unit/functions/http_fetcher_spec.js b/tests/unit/functions/http_fetcher_spec.js index b6e3900ad4..4b8ac222df 100644 --- a/tests/unit/functions/http_fetcher_spec.js +++ b/tests/unit/functions/http_fetcher_spec.js @@ -468,6 +468,62 @@ describe("fetch() method", () => { expect(text).toBe(responseData); }); + it("should resolve a dynamic URL for each fetch", async () => { + let requestNumber = 0; + const requestedUrls = []; + + server.use( + http.get("http://localhost/dynamic-1", ({ request }) => { + requestedUrls.push(request.url); + return HttpResponse.text("first"); + }), + http.get("http://localhost/dynamic-2", ({ request }) => { + requestedUrls.push(request.url); + return HttpResponse.text("second"); + }) + ); + + fetcher = new HTTPFetcher( + () => { + requestNumber += 1; + return `http://localhost/dynamic-${requestNumber}`; + }, + { reloadInterval: 60000 } + ); + + await fetcher.fetch(); + await fetcher.fetch(); + + expect(requestNumber).toBe(2); + expect(requestedUrls).toEqual([ + "http://localhost/dynamic-1", + "http://localhost/dynamic-2" + ]); + }); + + it("should include the resolved dynamic URL in error info", async () => { + const dynamicUrl = "http://localhost/dynamic-error"; + const urlProvider = vi.fn(() => dynamicUrl); + + server.use( + http.get(dynamicUrl, () => { + return new HttpResponse(null, { status: 500 }); + }) + ); + + fetcher = new HTTPFetcher(urlProvider, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", resolve); + }); + + await fetcher.fetch(); + const errorInfo = await errorPromise; + + expect(urlProvider).toHaveBeenCalledTimes(1); + expect(errorInfo.url).toBe(dynamicUrl); + }); + it("should emit error event on network error", async () => { server.use( http.get(TEST_URL, () => { diff --git a/tests/unit/modules/default/weather/providers/fmi_spec.js b/tests/unit/modules/default/weather/providers/fmi_spec.js new file mode 100644 index 0000000000..6219d1aa11 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/fmi_spec.js @@ -0,0 +1,793 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const FMI_WFS_PATTERN = "https://opendata.fmi.fi/wfs"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("FMIProvider", () => { + let FMIProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/fmi"); + FMIProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + expect(provider.config.lat).toBe(60.1699); + expect(provider.config.lon).toBe(24.9384); + expect(provider.config.type).toBe("current"); + expect(provider.config.updateInterval).toBe(10 * 60 * 1000); + }); + + it("should allow overriding default config values", () => { + const provider = new FMIProvider({ + type: "hourly", + updateInterval: 15 * 60 * 1000 + }); + + expect(provider.config.type).toBe("hourly"); + expect(provider.config.updateInterval).toBe(15 * 60 * 1000); + }); + }); + + describe("Coordinate Validation", () => { + it("should report invalid coordinates", async () => { + const provider = new FMIProvider({ + lat: Number.NaN, + lon: 24.9384 + }); + + const errorCallback = vi.fn(); + provider.setCallbacks(vi.fn(), errorCallback); + + await provider.initialize(); + + expect(errorCallback).toHaveBeenCalledOnce(); + expect(errorCallback.mock.calls[0][0]).toHaveProperty("message"); + }); + }); + + describe("URL Construction", () => { + it("should build an observation URL using configured coordinates", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const now = new Date("2026-09-20T07:34:36.000Z"); + const url = new URL(provider.buildObservationUrl(now)); + + expect(url.origin).toBe("https://opendata.fmi.fi"); + expect(url.pathname).toBe("/wfs"); + expect(url.searchParams.get("service")).toBe("WFS"); + expect(url.searchParams.get("version")).toBe("2.0.0"); + expect(url.searchParams.get("request")).toBe("getFeature"); + expect(url.searchParams.get("storedquery_id")).toBe( + "fmi::observations::weather::timevaluepair" + ); + expect(url.searchParams.get("bbox")).toBe("24.6884,59.9199,25.1884,60.4199"); + expect(url.searchParams.get("starttime")).toBe("2026-09-20T07:04:36.000Z"); + expect(url.searchParams.get("endtime")).toBe("2026-09-20T07:34:36.000Z"); + expect(url.searchParams.get("parameters")).toContain("t2m"); + expect(url.searchParams.get("parameters")).toContain("r_1h"); + }); + + it("should build a HARMONIE forecast URL using configured coordinates", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const url = new URL(provider.buildForecastUrl()); + + expect(url.origin).toBe("https://opendata.fmi.fi"); + expect(url.pathname).toBe("/wfs"); + expect(url.searchParams.get("service")).toBe("WFS"); + expect(url.searchParams.get("version")).toBe("2.0.0"); + expect(url.searchParams.get("request")).toBe("getFeature"); + expect(url.searchParams.get("storedquery_id")).toBe( + "fmi::forecast::harmonie::surface::point::timevaluepair" + ); + expect(url.searchParams.get("latlon")).toBe("60.1699,24.9384"); + expect(url.searchParams.get("parameters")).toContain("Temperature"); + expect(url.searchParams.get("parameters")).toContain("WeatherSymbol3"); + }); + }); + describe("Observation Parsing", () => { + it("should parse an FMI point time-series observation", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const xml = ` + + + + + 100968 + Vantaa Helsinki-Vantaan lentoasema + 60.32937 24.97274 + + + + 2026-09-20T07:30:00Z + 14.0 + + + + + 2026-09-20T07:40:00Z + 14.3 + + + + + + + `; + + expect(provider.parseObservationXml(xml)).toEqual([ + { + parameter: "t2m", + station: { + id: 100968, + name: "Vantaa Helsinki-Vantaan lentoasema", + lat: 60.32937, + lon: 24.97274 + }, + measurements: [ + { + time: "2026-09-20T07:30:00Z", + value: 14 + }, + { + time: "2026-09-20T07:40:00Z", + value: 14.3 + } + ] + } + ]); + }); + }); + describe("Observation Station Selection", () => { + it("should select the nearest station and latest measurements", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const observations = [ + { + parameter: "t2m", + station: { + id: 100971, + name: "Helsinki Kaisaniemi", + lat: 60.17523, + lon: 24.94459 + }, + measurements: [ + { + time: "2026-09-20T07:30:00Z", + value: 13.8 + }, + { + time: "2026-09-20T07:40:00Z", + value: 14.1 + } + ] + }, + { + parameter: "rh", + station: { + id: 100971, + name: "Helsinki Kaisaniemi", + lat: 60.17523, + lon: 24.94459 + }, + measurements: [ + { + time: "2026-09-20T07:30:00Z", + value: 81 + }, + { + time: "2026-09-20T07:40:00Z", + value: 79 + } + ] + }, + { + parameter: "ws_10min", + station: { + id: 100971, + name: "Helsinki Kaisaniemi", + lat: 60.17523, + lon: 24.94459 + }, + measurements: [ + { + time: "2026-09-20T07:40:00Z", + value: 3.2 + } + ] + }, + { + parameter: "wd_10min", + station: { + id: 100971, + name: "Helsinki Kaisaniemi", + lat: 60.17523, + lon: 24.94459 + }, + measurements: [ + { + time: "2026-09-20T07:40:00Z", + value: 210 + } + ] + }, + { + parameter: "p_sea", + station: { + id: 100971, + name: "Helsinki Kaisaniemi", + lat: 60.17523, + lon: 24.94459 + }, + measurements: [ + { + time: "2026-09-20T07:40:00Z", + value: 1013.2 + } + ] + }, + { + parameter: "t2m", + station: { + id: 100968, + name: "Vantaa Helsinki-Vantaan lentoasema", + lat: 60.32937, + lon: 24.97274 + }, + measurements: [ + { + time: "2026-09-20T07:40:00Z", + value: 14.3 + } + ] + } + ]; + + const result = provider.selectNearestObservationStation(observations); + + expect(result.station.id).toBe(100971); + expect(result.station.name).toBe("Helsinki Kaisaniemi"); + expect(result.station.distance).toBeGreaterThan(0); + expect(result.station.distance).toBeLessThan(1); + expect(result.values.t2m).toEqual({ + time: "2026-09-20T07:40:00Z", + value: 14.1 + }); + expect(result.values.rh).toEqual({ + time: "2026-09-20T07:40:00Z", + value: 79 + }); + }); + + it("should return null when no observations are available", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + expect(provider.selectNearestObservationStation([])).toBeNull(); + }); + it("should skip a nearer station when required observations are missing", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const createObservation = (parameter, station, value) => ({ + parameter, + station, + measurements: [ + { + time: "2026-09-20T07:40:00Z", + value + } + ] + }); + + const nearerStation = { + id: 1, + name: "Near station", + lat: 60.17, + lon: 24.94 + }; + + const completeStation = { + id: 2, + name: "Complete station", + lat: 60.2, + lon: 24.96 + }; + + const observations = [ + createObservation("t2m", nearerStation, 14.1), + createObservation("rh", nearerStation, 79), + + createObservation("t2m", completeStation, 14.0), + createObservation("rh", completeStation, 80), + createObservation("ws_10min", completeStation, 3.2), + createObservation("wd_10min", completeStation, 210), + createObservation("p_sea", completeStation, 1013.2) + ]; + + const result = provider.selectNearestObservationStation(observations); + + expect(result.station.id).toBe(2); + expect(result.station.name).toBe("Complete station"); + }); + }); + describe("Current Weather Generation", () => { + it("should convert selected FMI observations to MagicMirror weather data", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const selectedStation = { + station: { + id: 100971, + name: "Helsinki Kaisaniemi", + lat: 60.17523, + lon: 24.94459, + distance: 0.8 + }, + values: { + t2m: { + time: "2026-09-20T07:40:00Z", + value: 14.1 + }, + rh: { + time: "2026-09-20T07:40:00Z", + value: 79 + }, + ws_10min: { + time: "2026-09-20T07:40:00Z", + value: 3.2 + }, + wd_10min: { + time: "2026-09-20T07:40:00Z", + value: 210 + }, + wg_10min: { + time: "2026-09-20T07:40:00Z", + value: 5.6 + }, + p_sea: { + time: "2026-09-20T07:40:00Z", + value: 1013.2 + }, + r_1h: { + time: "2026-09-20T07:40:00Z", + value: 0.2 + } + } + }; + + const result = provider.generateCurrentWeather(selectedStation); + + expect(result.date).toEqual(new Date("2026-09-20T07:40:00Z")); + expect(result.temperature).toBe(14.1); + expect(result.humidity).toBe(79); + expect(result.windSpeed).toBe(3.2); + expect(result.windFromDirection).toBe(210); + expect(result.windGust).toBe(5.6); + expect(result.pressure).toBe(1013.2); + expect(result.precipitationAmount).toBe(0.2); + expect(provider.locationName).toBe("Helsinki Kaisaniemi"); + }); + }); + describe("Forecast Parsing", () => { + it("should combine FMI HARMONIE parameter time series by forecast time", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const xml = ` + + + + + + + + + + 2026-09-20T09:00:00Z + 15.1 + + + + + 2026-09-20T10:00:00Z + 15.5 + + + + + + + + + + + + + + + 2026-09-20T09:00:00Z + 72 + + + + + 2026-09-20T10:00:00Z + 68 + + + + + + + + `; + + const forecasts = provider.parseForecastXml(xml); + + expect(forecasts).toEqual([ + { + time: "2026-09-20T09:00:00Z", + Temperature: 15.1, + Humidity: 72 + }, + { + time: "2026-09-20T10:00:00Z", + Temperature: 15.5, + Humidity: 68 + } + ]); + }); + }); + describe("Hourly Forecast Generation", () => { + it("should convert parsed FMI forecasts to MagicMirror hourly weather data", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const forecasts = [ + { + time: "2026-09-20T09:00:00Z", + Temperature: 15.1, + Humidity: 72, + WindSpeedMS: 3.2, + WindDirection: 210, + WindGust: 5.1, + Pressure: 1013.2, + Precipitation1h: 0, + WeatherSymbol3: 2 + } + ]; + + const hourly = provider.generateHourlyForecast(forecasts); + + expect(hourly).toHaveLength(1); + expect(hourly[0]).toEqual({ + date: new Date("2026-09-20T09:00:00Z"), + temperature: 15.1, + humidity: 72, + windSpeed: 3.2, + windFromDirection: 210, + windGust: 5.1, + pressure: 1013.2, + precipitationAmount: 0, + weatherType: "day-cloudy" + }); + }); + + it("should map FMI weather symbols to MagicMirror weather types", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const symbols = [ + [1, "day-sunny"], + [2, "day-cloudy"], + [3, "cloudy"], + [22, "showers"], + [32, "rain"], + [42, "snow"], + [52, "snow"], + [61, "thunderstorm"], + [72, "sleet"], + [82, "sleet"], + [92, "fog"] + ]; + + for (const [symbol, expectedWeatherType] of symbols) { + const hourly = provider.generateHourlyForecast([ + { + time: "2026-09-20T09:00:00Z", + Temperature: 15, + Humidity: 70, + WindSpeedMS: 3, + WindDirection: 180, + WindGust: 5, + Pressure: 1013, + Precipitation1h: 0, + WeatherSymbol3: symbol + } + ]); + + expect(hourly[0].weatherType).toBe(expectedWeatherType); + } + }); + + it("should leave weather type undefined for an unknown FMI weather symbol", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384 + }); + + const hourly = provider.generateHourlyForecast([ + { + time: "2026-09-20T09:00:00Z", + Temperature: 15, + Humidity: 70, + WindSpeedMS: 3, + WindDirection: 180, + WindGust: 5, + Pressure: 1013, + Precipitation1h: 0, + WeatherSymbol3: 999 + } + ]); + + expect(hourly[0].weatherType).toBeUndefined(); + }); + }); + describe("Forecast Fetching", () => { + it("should fetch and process an FMI hourly forecast", async () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384, + type: "hourly" + }); + + const xml = ` + + + + + + + + 2026-09-20T09:00:00Z + 15.1 + + + + + + + + + + + + 2026-09-20T09:00:00Z + 72 + + + + + + + `; + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks(resolve, reject); + }); + + server.use( + http.get(FMI_WFS_PATTERN, () => new HttpResponse(xml, { + headers: { "Content-Type": "application/xml" } + })) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(1); + expect(result[0].date).toEqual(new Date("2026-09-20T09:00:00Z")); + expect(result[0].temperature).toBe(15.1); + expect(result[0].humidity).toBe(72); + + provider.stop(); + }); + it("should fetch and process an FMI daily forecast", async () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384, + type: "forecast" + }); + + const xml = ` + + + + + + + + 2026-09-20T04:00:00Z + 8.2 + + + + + 2026-09-20T09:00:00Z + 15.1 + + + + + + + + + + + + 2026-09-20T04:00:00Z + 0 + + + + + 2026-09-20T09:00:00Z + 0.4 + + + + + + + + + + + + 2026-09-20T04:00:00Z + 1 + + + + + 2026-09-20T09:00:00Z + 2 + + + + + + + `; + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks(resolve, reject); + }); + + server.use( + http.get(FMI_WFS_PATTERN, () => new HttpResponse(xml, { + headers: { "Content-Type": "application/xml" } + })) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + date: new Date("2026-09-20T04:00:00Z"), + minTemperature: 8.2, + maxTemperature: 15.1, + weatherType: "day-cloudy" + }); + expect(result[0].precipitationAmount).toBeCloseTo(0.4); + + provider.stop(); + }); + }); + describe("Daily Forecast Generation", () => { + it("should aggregate FMI forecasts into Finnish local calendar days", () => { + const provider = new FMIProvider({ + lat: 60.1699, + lon: 24.9384, + type: "forecast" + }); + + const forecasts = [ + { + time: "2026-09-20T04:00:00Z", + Temperature: 8.2, + Precipitation1h: 0, + WeatherSymbol3: 1 + }, + { + time: "2026-09-20T09:00:00Z", + Temperature: 15.1, + Precipitation1h: 0.4, + WeatherSymbol3: 2 + }, + { + time: "2026-09-20T18:00:00Z", + Temperature: 10.3, + Precipitation1h: 0.2, + WeatherSymbol3: 7 + } + ]; + + const daily = provider.generateDailyForecast(forecasts); + + expect(daily).toHaveLength(1); + expect(daily[0]).toMatchObject({ + date: new Date("2026-09-20T04:00:00Z"), + minTemperature: 8.2, + maxTemperature: 15.1, + weatherType: "day-cloudy" + }); + expect(daily[0].precipitationAmount).toBeCloseTo(0.6); + }); + }); +});