diff --git a/defaultmodules/weather/providers/buienradar.js b/defaultmodules/weather/providers/buienradar.js index ec4ebf5e62..a10e2cdc63 100644 --- a/defaultmodules/weather/providers/buienradar.js +++ b/defaultmodules/weather/providers/buienradar.js @@ -1,5 +1,5 @@ const Log = require("logger"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); const BUIENRADAR_API_BASE = "https://forecast.buienradar.nl/2.0/forecast"; const ERROR_TRANSLATION_KEY = "MODULE_ERROR_UNSPECIFIED"; @@ -59,8 +59,9 @@ const WEATHER_ICON_MAP = { * Netherlands/Belgium only, metric system, no API key required * see https://buienradar.nl */ -class BuienradarProvider { +class BuienradarProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiBase: BUIENRADAR_API_BASE, locationId: null, @@ -69,11 +70,6 @@ class BuienradarProvider { updateInterval: 10 * 60 * 1000, ...config }; - - this.locationName = null; - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; } initialize () { @@ -86,46 +82,13 @@ class BuienradarProvider { this.#initializeFetcher(); } - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } #initializeFetcher () { - this.fetcher = new HTTPFetcher(this.#getUrl(), { + this._createJSONFetcher(this.#getUrl(), { reloadInterval: this.config.updateInterval, headers: { "Cache-Control": "no-cache" }, logContext: "weatherprovider.buienradar" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[buienradar] Failed to parse JSON:", error); - this.#sendErrorCallback("Failed to parse API response"); - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #handleResponse (data) { diff --git a/defaultmodules/weather/providers/envcanada.js b/defaultmodules/weather/providers/envcanada.js index 56f86678ab..f4e4cac6a6 100644 --- a/defaultmodules/weather/providers/envcanada.js +++ b/defaultmodules/weather/providers/envcanada.js @@ -1,5 +1,6 @@ const Log = require("logger"); const { convertKmhToMs } = require("../provider-utils"); +const WeatherProvider = require("../weatherprovider"); const HTTPFetcher = require("#http_fetcher"); /** @@ -13,8 +14,9 @@ const HTTPFetcher = require("#http_fetcher"); * Requires siteCode and provCode config parameters * See https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv */ -class EnvCanadaProvider { +class EnvCanadaProvider extends WeatherProvider { constructor (config) { + super(); this.config = { siteCode: "s0000000", provCode: "ON", @@ -23,9 +25,6 @@ class EnvCanadaProvider { ...config }; - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; this.lastCityPageURL = null; this.cacheCurrentTemp = null; this.currentHour = null; // Track current hour for URL updates @@ -36,23 +35,6 @@ class EnvCanadaProvider { this.#initializeFetcher(); } - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } - #validateConfig () { if (!this.config.siteCode || !this.config.provCode) { throw new Error("siteCode and provCode are required"); diff --git a/defaultmodules/weather/providers/openmeteo.js b/defaultmodules/weather/providers/openmeteo.js index e366e95b5e..c48d22a0a9 100644 --- a/defaultmodules/weather/providers/openmeteo.js +++ b/defaultmodules/weather/providers/openmeteo.js @@ -1,5 +1,5 @@ const Log = require("logger"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); // https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client"; @@ -9,7 +9,7 @@ const OPEN_METEO_BASE = "https://api.open-meteo.com/v1"; * Server-side weather provider for Open-Meteo * see https://open-meteo.com/ */ -class OpenMeteoProvider { +class OpenMeteoProvider extends WeatherProvider { // https://open-meteo.com/en/docs hourlyParams = [ "temperature_2m", @@ -90,6 +90,7 @@ class OpenMeteoProvider { ]; constructor (config) { + super(); this.config = { apiBase: OPEN_METEO_BASE, lat: 0, @@ -100,11 +101,6 @@ class OpenMeteoProvider { updateInterval: 10 * 60 * 1000, ...config }; - - this.locationName = null; - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; } async initialize () { @@ -112,34 +108,6 @@ class OpenMeteoProvider { this.#initializeFetcher(); } - /** - * Set callbacks for data/error events - * @param {(data: object) => void} onData - Called with weather data - * @param {(error: object) => void} onError - Called with error info - */ - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - /** - * Start periodic fetching - */ - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - /** - * Stop periodic fetching - */ - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } - async #fetchLocation () { const url = `${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang || "en"}`; @@ -165,33 +133,11 @@ class OpenMeteoProvider { #initializeFetcher () { const url = this.#getUrl(); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, headers: { "Cache-Control": "no-cache" }, logContext: "weatherprovider.openmeteo" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[openmeteo] Failed to parse JSON:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #handleResponse (data) { diff --git a/defaultmodules/weather/providers/openweathermap.js b/defaultmodules/weather/providers/openweathermap.js index 5dd44f9ca9..1e31e6083a 100644 --- a/defaultmodules/weather/providers/openweathermap.js +++ b/defaultmodules/weather/providers/openweathermap.js @@ -1,13 +1,14 @@ const Log = require("logger"); const weatherUtils = require("../provider-utils"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); /** * Server-side weather provider for OpenWeatherMap * see https://openweathermap.org/ */ -class OpenWeatherMapProvider { +class OpenWeatherMapProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiVersion: "3.0", apiBase: "https://api.openweathermap.org/data/", @@ -21,11 +22,6 @@ class OpenWeatherMapProvider { updateInterval: 10 * 60 * 1000, ...config }; - - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; - this.locationName = null; } initialize () { @@ -46,53 +42,15 @@ class OpenWeatherMapProvider { this.#initializeFetcher(); } - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } #initializeFetcher () { const url = this.#getUrl(); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, headers: { "Cache-Control": "no-cache" }, logContext: "weatherprovider.openweathermap" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[openweathermap] Failed to parse JSON:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #handleResponse (data) { diff --git a/defaultmodules/weather/providers/pirateweather.js b/defaultmodules/weather/providers/pirateweather.js index ec7fec6755..5ffdbaa295 100644 --- a/defaultmodules/weather/providers/pirateweather.js +++ b/defaultmodules/weather/providers/pirateweather.js @@ -1,8 +1,9 @@ const Log = require("logger"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); -class PirateweatherProvider { +class PirateweatherProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiBase: "https://api.pirateweather.net", weatherEndpoint: "/forecast", @@ -14,14 +15,6 @@ class PirateweatherProvider { lang: "en", ...config }; - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; - } - - setCallbacks (onDataCallback, onErrorCallback) { - this.onDataCallback = onDataCallback; - this.onErrorCallback = onErrorCallback; } initialize () { @@ -42,36 +35,14 @@ class PirateweatherProvider { #initializeFetcher () { const url = this.#getUrl(); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, headers: { "Cache-Control": "no-cache", Accept: "application/json" }, logContext: "weatherprovider.pirateweather" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[pirateweather] Parse error:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #handleResponse (data) { @@ -255,17 +226,6 @@ class PirateweatherProvider { return weatherTypes[weatherType] || null; } - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } } module.exports = PirateweatherProvider; diff --git a/defaultmodules/weather/providers/smhi.js b/defaultmodules/weather/providers/smhi.js index 7490829898..5e6f696ded 100644 --- a/defaultmodules/weather/providers/smhi.js +++ b/defaultmodules/weather/providers/smhi.js @@ -1,6 +1,6 @@ const Log = require("logger"); const { getSunTimes, isDayTime, validateCoordinates } = require("../provider-utils"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); /** * Server-side weather provider for SMHI (Swedish Meteorological and Hydrological Institute) @@ -31,8 +31,9 @@ const PRECIP_VALUE_MAP = { pmax: "precipitation_amount_max" }; -class SMHIProvider { +class SMHIProvider extends WeatherProvider { constructor (config) { + super(); this.config = { lat: 0, lon: 0, @@ -47,10 +48,6 @@ class SMHIProvider { Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`); this.config.precipitationValue = "pmedian"; } - - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; } initialize () { @@ -69,52 +66,14 @@ class SMHIProvider { } } - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } #initializeFetcher () { const url = this.#getUrl(); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, logContext: "weatherprovider.smhi" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[smhi] Failed to parse JSON:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #handleResponse (data) { diff --git a/defaultmodules/weather/providers/ukmetofficedatahub.js b/defaultmodules/weather/providers/ukmetofficedatahub.js index 80cdba5a9f..5d13249415 100644 --- a/defaultmodules/weather/providers/ukmetofficedatahub.js +++ b/defaultmodules/weather/providers/ukmetofficedatahub.js @@ -1,6 +1,6 @@ const Log = require("logger"); const { getSunTimes } = require("../provider-utils"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); /** * UK Met Office Data Hub provider @@ -13,8 +13,9 @@ const HTTPFetcher = require("#http_fetcher"); * * Free accounts limited to 360 requests/day per service (once every 4 minutes) */ -class UkMetOfficeDataHubProvider { +class UkMetOfficeDataHubProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", apiKey: "", @@ -24,15 +25,6 @@ class UkMetOfficeDataHubProvider { updateInterval: 10 * 60 * 1000, ...config }; - - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; - } - - setCallbacks (onDataCallback, onErrorCallback) { - this.onDataCallback = onDataCallback; - this.onErrorCallback = onErrorCallback; } initialize () { @@ -54,36 +46,14 @@ class UkMetOfficeDataHubProvider { const forecastType = this.#getForecastType(); const url = this.#getUrl(forecastType); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, headers: { Accept: "application/json", apikey: this.config.apiKey }, logContext: "weatherprovider.ukmetofficedatahub" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[ukmetofficedatahub] Parse error:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #getForecastType () { @@ -314,17 +284,6 @@ class UkMetOfficeDataHubProvider { return weatherTypes[weatherType] || null; } - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } } module.exports = UkMetOfficeDataHubProvider; diff --git a/defaultmodules/weather/providers/weatherapi.js b/defaultmodules/weather/providers/weatherapi.js index 30124b8029..479923cbec 100644 --- a/defaultmodules/weather/providers/weatherapi.js +++ b/defaultmodules/weather/providers/weatherapi.js @@ -1,11 +1,12 @@ const Log = require("logger"); const { convertKmhToMs, cardinalToDegrees } = require("../provider-utils"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); const WEATHER_API_BASE = "https://api.weatherapi.com/v1"; -class WeatherAPIProvider { +class WeatherAPIProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiBase: WEATHER_API_BASE, lat: 0, @@ -18,11 +19,6 @@ class WeatherAPIProvider { updateInterval: 10 * 60 * 1000, ...config }; - - this.locationName = null; - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; } initialize () { @@ -30,22 +26,6 @@ class WeatherAPIProvider { this.#initializeFetcher(); } - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } #validateConfig () { this.config.type = `${this.config.type ?? ""}`.trim().toLowerCase(); @@ -70,32 +50,11 @@ class WeatherAPIProvider { #initializeFetcher () { const url = this.#getUrl(); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, headers: { "Cache-Control": "no-cache" }, logContext: "weatherprovider.weatherapi" - }); - - this.fetcher.on("response", async (response) => { - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[weatherapi] Failed to parse JSON:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #handleResponse (data) { diff --git a/defaultmodules/weather/providers/weatherbit.js b/defaultmodules/weather/providers/weatherbit.js index 32c261fed0..5295ba49e0 100644 --- a/defaultmodules/weather/providers/weatherbit.js +++ b/defaultmodules/weather/providers/weatherbit.js @@ -1,12 +1,13 @@ const Log = require("logger"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); /** * Weatherbit weather provider * See: https://www.weatherbit.io/ */ -class WeatherbitProvider { +class WeatherbitProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiBase: "https://api.weatherbit.io/v2.0", apiKey: "", @@ -16,15 +17,6 @@ class WeatherbitProvider { updateInterval: 10 * 60 * 1000, ...config }; - - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; - } - - setCallbacks (onDataCallback, onErrorCallback) { - this.onDataCallback = onDataCallback; - this.onErrorCallback = onErrorCallback; } initialize () { @@ -45,34 +37,13 @@ class WeatherbitProvider { #initializeFetcher () { const url = this.#getUrl(); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, headers: { Accept: "application/json" }, logContext: "weatherprovider.weatherbit" - }); - - this.fetcher.on("response", async (response) => { - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[weatherbit] Parse error:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #getUrl () { @@ -276,17 +247,6 @@ class WeatherbitProvider { return weatherTypes[weatherType] || null; } - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } } module.exports = WeatherbitProvider; diff --git a/defaultmodules/weather/providers/weatherflow.js b/defaultmodules/weather/providers/weatherflow.js index 55952ebcb3..46d706917a 100644 --- a/defaultmodules/weather/providers/weatherflow.js +++ b/defaultmodules/weather/providers/weatherflow.js @@ -1,37 +1,22 @@ const Log = require("logger"); const { convertKmhToMs } = require("../provider-utils"); -const HTTPFetcher = require("#http_fetcher"); +const WeatherProvider = require("../weatherprovider"); /** * WeatherFlow weather provider * This class is a provider for WeatherFlow personal weather stations. * Note that the WeatherFlow API does not provide snowfall. */ -class WeatherFlowProvider { +class WeatherFlowProvider extends WeatherProvider { /** * @param {object} config - Provider configuration */ constructor (config) { + super(); this.config = config; - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; } - /** - * Set the callbacks for data and errors - * @param {(data: object) => void} onDataCallback - Called when new data is available - * @param {(error: object) => void} onErrorCallback - Called when an error occurs - */ - setCallbacks (onDataCallback, onErrorCallback) { - this.onDataCallback = onDataCallback; - this.onErrorCallback = onErrorCallback; - } - - /** - * Initialize the provider - */ initialize () { if (!this.config.token || this.config.token === "YOUR_API_TOKEN_HERE") { Log.error("[weatherflow] No API token configured. Get one at https://tempestwx.com/"); @@ -64,31 +49,16 @@ class WeatherFlowProvider { #initializeFetcher () { const url = this.#getUrl(); - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, headers: { "Cache-Control": "no-cache", Accept: "application/json" }, logContext: "weatherprovider.weatherflow" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - const processed = this.#processData(data); - this.onDataCallback(processed); - } catch (error) { - Log.error("[weatherflow] Failed to parse JSON:", error); - } - }); - - this.fetcher.on("error", (errorInfo) => { - // HTTPFetcher already logged the error with logContext - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } + }, (data) => { + const processed = this.#processData(data); + this.onDataCallback(processed); }); } @@ -280,23 +250,6 @@ class WeatherFlowProvider { return weatherTypes[weatherType] || null; } - /** - * Start fetching data - */ - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - /** - * Stop fetching data - */ - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } } module.exports = WeatherFlowProvider; diff --git a/defaultmodules/weather/providers/weathergov.js b/defaultmodules/weather/providers/weathergov.js index 4269c0e811..d2277c75fb 100644 --- a/defaultmodules/weather/providers/weathergov.js +++ b/defaultmodules/weather/providers/weathergov.js @@ -1,5 +1,6 @@ const Log = require("logger"); const { getSunTimes, isDayTime, getDateString, convertKmhToMs, cardinalToDegrees } = require("../provider-utils"); +const WeatherProvider = require("../weatherprovider"); const HTTPFetcher = require("#http_fetcher"); /** @@ -7,8 +8,9 @@ const HTTPFetcher = require("#http_fetcher"); * Note: Only works for US locations, no API key required * https://weather-gov.github.io/api/general-faqs */ -class WeatherGovProvider { +class WeatherGovProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiBase: "https://api.weather.gov/points/", lat: 0, @@ -18,10 +20,6 @@ class WeatherGovProvider { ...config }; - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; - this.locationName = null; this.initRetryCount = 0; this.initRetryTimer = null; @@ -91,25 +89,10 @@ class WeatherGovProvider { }; } - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - if (this.initRetryTimer) { - clearTimeout(this.initRetryTimer); - this.initRetryTimer = null; - } + super.stop(); + clearTimeout(this.initRetryTimer); + this.initRetryTimer = null; } async #fetchWeatherGovURLs () { @@ -195,7 +178,7 @@ class WeatherGovProvider { url = this.stationObsURL; } - this.fetcher = new HTTPFetcher(url, { + this._createJSONFetcher(url, { reloadInterval: this.config.updateInterval, timeout: 60000, // 60 seconds - weather.gov can be slow headers: { @@ -204,29 +187,7 @@ class WeatherGovProvider { "Cache-Control": "no-cache" }, logContext: "weatherprovider.weathergov" - }); - - this.fetcher.on("response", async (response) => { - if (response.status === 304) return; - try { - const data = await response.json(); - this.#handleResponse(data); - } catch (error) { - Log.error("[weathergov] Failed to parse JSON:", error); - if (this.onErrorCallback) { - this.onErrorCallback({ - message: "Failed to parse API response", - translationKey: "MODULE_ERROR_UNSPECIFIED" - }); - } - } - }); - - this.fetcher.on("error", (errorInfo) => { - if (this.onErrorCallback) { - this.onErrorCallback(errorInfo); - } - }); + }, (data) => this.#handleResponse(data)); } #handleResponse (data) { diff --git a/defaultmodules/weather/providers/yr.js b/defaultmodules/weather/providers/yr.js index 9b44571f9e..f8b3670743 100644 --- a/defaultmodules/weather/providers/yr.js +++ b/defaultmodules/weather/providers/yr.js @@ -1,5 +1,6 @@ const Log = require("logger"); const { formatTimezoneOffset, getDateString, validateCoordinates } = require("../provider-utils"); +const WeatherProvider = require("../weatherprovider"); const HTTPFetcher = require("#http_fetcher"); /** @@ -8,8 +9,9 @@ const HTTPFetcher = require("#http_fetcher"); * * Note: Minimum update interval is 10 minutes (600000 ms) per API terms */ -class YrProvider { +class YrProvider extends WeatherProvider { constructor (config) { + super(); this.config = { apiBase: "https://api.met.no/weatherapi", forecastApiVersion: "2.0", @@ -29,11 +31,7 @@ class YrProvider { this.config.updateInterval = 600000; } - this.fetcher = null; - this.onDataCallback = null; - this.onErrorCallback = null; - this.locationName = null; - + this.fetcher = null; // yr manages its own fetcher with If-Modified-Since caching // Cache for sunrise/sunset data this.stellarData = null; this.stellarDataDate = null; @@ -53,23 +51,6 @@ class YrProvider { this.#initializeFetcher(); } - setCallbacks (onData, onError) { - this.onDataCallback = onData; - this.onErrorCallback = onError; - } - - start () { - if (this.fetcher) { - this.fetcher.startPeriodicFetch(); - } - } - - stop () { - if (this.fetcher) { - this.fetcher.clearTimer(); - } - } - async #fetchStellarData () { const today = getDateString(new Date()); diff --git a/defaultmodules/weather/weatherprovider.js b/defaultmodules/weather/weatherprovider.js new file mode 100644 index 0000000000..8e4a541917 --- /dev/null +++ b/defaultmodules/weather/weatherprovider.js @@ -0,0 +1,87 @@ +const Log = require("logger"); +const HTTPFetcher = require("#http_fetcher"); + +/** + * Base class for server-side weather providers. + * + * Centralizes the HTTPFetcher lifecycle (start, stop, callbacks) and standard + * JSON-over-HTTP handling: 304 Not Modified, JSON parse errors, HTTP errors. + * Providers extend this class and call _createJSONFetcher() in their initialize(). + */ +class WeatherProvider { + + constructor () { + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + this.locationName = null; + } + + /** + * Sets the callbacks used to deliver data and report errors. + * Must be called before initialize(). + * @param {(data: object) => void} onData - Called with the parsed weather data object + * @param {(error: object) => void} onError - Called with an error info object + */ + setCallbacks (onData, onError) { + this.onDataCallback = onData; + this.onErrorCallback = onError; + } + + /** Start periodic fetching. */ + start () { + this.fetcher?.startPeriodicFetch(); + } + + /** Stop periodic fetching. */ + stop () { + this.fetcher?.clearTimer(); + } + + /** + * Creates an HTTPFetcher for a JSON API, stores it as this.fetcher, and wires + * up response/error handling centrally: + * - HTTP 304 Not Modified: skips parsing, keeps existing data on screen + * - JSON parse failures: calls onError without clearing existing data + * - HTTP errors: forwarded directly to onError + * @param {string} url - The URL to fetch + * @param {object} options - Options forwarded to HTTPFetcher + * @param {(data: object) => void} onData - Called with the parsed JSON object on success + */ + _createJSONFetcher (url, options, onData) { + this.fetcher = new HTTPFetcher(url, options); + + this.fetcher.on("response", async (response) => { + // 304 has no body — skip parsing, keep existing data + if (response.status === 304) return; + + let data; + try { + data = await response.json(); + } catch (error) { + Log.error(`[${this.constructor.name}] Failed to parse JSON:`, error); + this.onErrorCallback?.({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + return; + } + + try { + onData(data); + } catch (error) { + Log.error(`[${this.constructor.name}] Failed to process weather data:`, error); + this.onErrorCallback?.({ + message: "Failed to process weather data", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + }); + + this.fetcher.on("error", (errorInfo) => { + this.onErrorCallback?.(errorInfo); + }); + } +} + +module.exports = WeatherProvider; diff --git a/eslint.config.mjs b/eslint.config.mjs index 921be57464..cac0dffe3d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -179,7 +179,7 @@ export default defineConfig([ } }, { - files: ["tests/unit/modules/default/weather/providers/*.js"], + files: ["tests/unit/modules/default/weather/**/*.js"], rules: { "import-x/namespace": "off", "import-x/named": "off", diff --git a/tests/unit/modules/default/weather/weatherprovider_spec.js b/tests/unit/modules/default/weather/weatherprovider_spec.js new file mode 100644 index 0000000000..73ad0df818 --- /dev/null +++ b/tests/unit/modules/default/weather/weatherprovider_spec.js @@ -0,0 +1,100 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const TEST_URL = "https://weather-provider.test/data"; +const server = setupServer(); + +class TestProvider { + constructor (WeatherProvider, onData) { + this.provider = new WeatherProvider(); + this.onData = onData; + } + + setCallbacks (onData, onError) { + this.provider.setCallbacks(onData, onError); + } + + initialize () { + this.provider._createJSONFetcher(TEST_URL, { reloadInterval: 60000 }, this.onData); + } + + start () { + this.provider.start(); + } + + stop () { + this.provider.stop(); + } +} + +let WeatherProvider; + +beforeAll(async () => { + server.listen({ onUnhandledRequest: "error" }); + const module = await import("../../../../../defaultmodules/weather/weatherprovider"); + WeatherProvider = module.default || module; +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherProvider", () => { + it("should ignore 304 responses", async () => { + server.use( + http.get(TEST_URL, () => new HttpResponse(null, { status: 304 })) + ); + const onData = vi.fn(); + const provider = new TestProvider(WeatherProvider, onData); + provider.setCallbacks(onData, vi.fn()); + provider.initialize(); + provider.start(); + + await vi.waitFor(() => expect(provider.provider.fetcher).toBeDefined()); + await new Promise((resolve) => setTimeout(resolve, 25)); + provider.stop(); + + expect(onData).not.toHaveBeenCalled(); + }); + + it("should report JSON parse failures", async () => { + server.use( + http.get(TEST_URL, () => HttpResponse.text("not json")) + ); + const onError = vi.fn(); + const provider = new TestProvider(WeatherProvider, vi.fn()); + provider.setCallbacks(vi.fn(), onError); + provider.initialize(); + provider.start(); + + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + })); + provider.stop(); + }); + + it("should report data processing failures separately", async () => { + server.use( + http.get(TEST_URL, () => HttpResponse.json({ forecast: [] })) + ); + const onError = vi.fn(); + const provider = new TestProvider(WeatherProvider, () => { + throw new Error("processing failed"); + }); + provider.setCallbacks(vi.fn(), onError); + provider.initialize(); + provider.start(); + + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith({ + message: "Failed to process weather data", + translationKey: "MODULE_ERROR_UNSPECIFIED" + })); + provider.stop(); + }); +});