diff --git a/cspell.config.json b/cspell.config.json index b5cc6987ba..51af23e5ec 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -13,9 +13,9 @@ "armv", "ashishtank", "autoplay", + "Autorestart", "avghumidity", "avgtemp", - "Autorestart", "beada", "Behaviour", "Beschreibung", @@ -32,6 +32,7 @@ "btoconnor", "bughaver", "bugsounet", + "buienradar", "buxxi", "byday", "calcage", @@ -183,8 +184,8 @@ "Lightspeed", "loadingcircle", "locationforecast", - "logg", "lockstring", + "logg", "lstrip", "Luciella", "luxon", @@ -201,8 +202,8 @@ "Meteo", "michaelteeuw", "michmich", - "mintemp", "Midori", + "mintemp", "mirontoli", "MISSINGLANG", "mixasgr", @@ -220,8 +221,8 @@ "NEWSFEED", "newsfeedfetcher", "newsfetcher", - "newyear", "newsitems", + "newyear", "nextdaysrelative", "nfogal", "njwilliams", @@ -260,8 +261,8 @@ "Reis", "rejas", "relativehumidity", - "resultstring", "Resig", + "resultstring", "roboto", "rohitdharavath", "Rosso", @@ -301,8 +302,8 @@ "Teil", "TESTMODE", "testpass", - "testuser", "teststring", + "testuser", "thomasrockhu", "thumbslider", "timeformat", @@ -323,12 +324,12 @@ "updatenotification", "uxdt", "Vaice", + "VCALENDAR", "veeck", "verjaardag", "VEVENT", "vgtu", "Vitest", - "VCALENDAR", "Voelt", "Vorberechnung", "vppencilsharpener", diff --git a/defaultmodules/weather/providers/buienradar.js b/defaultmodules/weather/providers/buienradar.js new file mode 100644 index 0000000000..c49c5fed50 --- /dev/null +++ b/defaultmodules/weather/providers/buienradar.js @@ -0,0 +1,348 @@ +const Log = require("logger"); +const HTTPFetcher = require("#http_fetcher"); + +const BUIENRADAR_API_BASE = "https://forecast.buienradar.nl/2.0/forecast"; +const ERROR_TRANSLATION_KEY = "MODULE_ERROR_UNSPECIFIED"; +const TIMESTAMP_HAS_TIME_ZONE = /[zZ]|[+-]\d{2}:?\d{2}$/; + +// Mapping from Buienradar icon code to weather-icons class names (https://erikflowers.github.io/weather-icons/). +// Icon filenames use the Buienradar code: https://cdn.buienradar.nl/resources/images/icons/weather/30x30/a.png +const WEATHER_ICON_MAP = { + a: "day-sunny", + aa: "night-clear", + b: "day-cloudy", + bb: "night-alt-cloudy", + c: "cloudy", + cc: "cloudy", + d: "day-fog", + dd: "night-fog", + f: "day-sprinkle", + ff: "night-alt-sprinkle", + g: "day-storm-showers", + gg: "night-alt-storm-showers", + h: "day-rain", + hh: "night-alt-rain", + i: "day-rain-mix", + ii: "night-alt-rain-mix", + j: "day-cloudy", + jj: "night-alt-cloudy", + k: "day-showers", + kk: "night-alt-showers", + l: "showers", + ll: "showers", + m: "sprinkle", + mm: "sprinkle", + n: "day-haze", + nn: "night-fog", + o: "day-cloudy", + oo: "night-alt-cloudy", + p: "cloudy", + pp: "cloudy", + q: "showers", + qq: "showers", + r: "day-cloudy", + rr: "night-alt-cloudy", + s: "thunderstorm", + ss: "thunderstorm", + t: "snow", + tt: "snow", + u: "day-snow", + uu: "night-alt-snow", + v: "snow", + vv: "snow", + w: "rain-mix", + ww: "rain-mix" +}; + +/** + * Server-side weather provider for Buienradar + * Netherlands/Belgium only, metric system, no API key required + * see https://buienradar.nl + */ +class BuienradarProvider { + constructor (config) { + this.config = { + apiBase: BUIENRADAR_API_BASE, + locationId: null, + type: "current", + maxEntries: 5, + updateInterval: 10 * 60 * 1000, + ...config + }; + + this.locationName = null; + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + } + + initialize () { + if (!this.config.locationId) { + Log.error("[buienradar] No locationId configured"); + this.#sendErrorCallback("Buienradar locationId required. See https://www.buienradar.nl/overbuienradar/gratis-weerdata"); + return; + } + + 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(), { + reloadInterval: this.config.updateInterval, + headers: { "Cache-Control": "no-cache" }, + logContext: "weatherprovider.buienradar" + }); + + this.fetcher.on("response", async (response) => { + 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); + } + }); + } + + #handleResponse (data) { + try { + if (!Array.isArray(data?.days) || data.days.length === 0) { + throw new Error("Invalid API response"); + } + + this.#setLocationName(data.location); + + let weatherData; + switch (this.config.type) { + case "current": + weatherData = this.#generateCurrentWeather(data.days[0]); + break; + case "forecast": + case "daily": + weatherData = this.#generateDailyForecast(data.days); + break; + case "hourly": + weatherData = this.#generateHourlyForecast(data.days); + break; + default: + throw new Error(`Unknown weather type: ${this.config.type}`); + } + + if (this.onDataCallback && weatherData) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[buienradar] Error processing weather data:", error); + this.#sendErrorCallback(error.message); + } + } + + #sendErrorCallback (message) { + if (this.onErrorCallback) { + this.onErrorCallback({ + message, + translationKey: ERROR_TRANSLATION_KEY + }); + } + } + + #setLocationName (location) { + if (location?.name) { + this.locationName = location.name; + } + } + + #generateCurrentWeather (day) { + const closestHour = this.#getClosestHour(day.hours ?? []); + const weather = this.#parseHour(closestHour); + + const sunrise = this.#parseDate(day.sunrise); + if (sunrise) weather.sunrise = sunrise; + + const sunset = this.#parseDate(day.sunset); + if (sunset) weather.sunset = sunset; + + const minTemperature = this.#parseNumber(day.mintemp); + if (minTemperature !== null) weather.minTemperature = minTemperature; + + const maxTemperature = this.#parseNumber(day.maxtemp); + if (maxTemperature !== null) weather.maxTemperature = maxTemperature; + + return weather; + } + + #generateDailyForecast (days) { + return days + .slice(0, this.config.maxEntries) + .map((day) => this.#parseDay(day)); + } + + #generateHourlyForecast (days) { + const hours = []; + + for (const day of days) { + for (const hour of day.hours ?? []) { + hours.push(this.#parseHour(hour)); + if (hours.length >= this.config.maxEntries) { + return hours; + } + } + } + + return hours; + } + + #parseDay (day) { + const weather = {}; + + const date = this.#parseDate(day.date); + if (date) weather.date = date; + + const minTemperature = this.#parseNumber(day.mintemp); + if (minTemperature !== null) weather.minTemperature = minTemperature; + + const maxTemperature = this.#parseNumber(day.maxtemp); + if (maxTemperature !== null) weather.maxTemperature = maxTemperature; + + const humidity = this.#parseNumber(day.humidity); + if (humidity !== null) weather.humidity = humidity; + + const windSpeed = this.#parseNumber(day.windspeedms); + if (windSpeed !== null) weather.windSpeed = windSpeed; + + const windFromDirection = this.#parseNumber(day.winddirectiondegrees); + if (windFromDirection !== null) weather.windFromDirection = windFromDirection; + + this.#applyPrecipitation(weather, day); + + const sunrise = this.#parseDate(day.sunrise); + if (sunrise) weather.sunrise = sunrise; + + const sunset = this.#parseDate(day.sunset); + if (sunset) weather.sunset = sunset; + + weather.weatherType = this.#convertWeatherType(day.iconcode); + + return weather; + } + + #parseHour (hour) { + const weather = {}; + + const date = this.#parseDate(hour.datetime); + if (date) weather.date = date; + + const temperature = this.#parseNumber(hour.temperature); + if (temperature !== null) weather.temperature = temperature; + + const feelsLikeTemp = this.#parseNumber(hour.feeltemperature); + if (feelsLikeTemp !== null) weather.feelsLikeTemp = feelsLikeTemp; + + const humidity = this.#parseNumber(hour.humidity); + if (humidity !== null) weather.humidity = humidity; + + const windSpeed = this.#parseNumber(hour.windspeedms); + if (windSpeed !== null) weather.windSpeed = windSpeed; + + const windFromDirection = this.#parseNumber(hour.winddirectiondegrees); + if (windFromDirection !== null) weather.windFromDirection = windFromDirection; + + this.#applyPrecipitation(weather, hour); + + weather.weatherType = this.#convertWeatherType(hour.iconcode); + + return weather; + } + + #applyPrecipitation (weather, source) { + const precipitationAmount = this.#parseNumber(source.precipitationmm); + if (precipitationAmount !== null) { + weather.precipitationAmount = precipitationAmount; + weather.precipitationUnits = "mm"; + } + + const precipitationProbability = this.#parseNumber(source.precipitation); + if (precipitationProbability !== null) { + weather.precipitationProbability = precipitationProbability; + } + } + + #getClosestHour (hours) { + if (hours.length === 0) { + return {}; + } + + const now = Date.now(); + let closest = hours[0]; + let closestDiff = Number.POSITIVE_INFINITY; + + for (const hour of hours) { + const date = this.#parseDate(hour.datetime); + if (!date) continue; + + const diff = Math.abs(date.getTime() - now); + if (diff < closestDiff) { + closestDiff = diff; + closest = hour; + } + } + + return closest; + } + + #getUrl () { + const now = new Date(); + const year = now.getUTCFullYear(); + const month = `${now.getUTCMonth() + 1}`.padStart(2, "0"); + const day = `${now.getUTCDate()}`.padStart(2, "0"); + const hours = `${now.getUTCHours()}`.padStart(2, "0"); + const minutes = `${now.getUTCMinutes()}`.padStart(2, "0"); + const cacheBust = `${year}${month}${day}${hours}${minutes}`; + const params = new URLSearchParams({ btc: cacheBust }); + + return `${this.config.apiBase}/${this.config.locationId}?${params}`; + } + + #parseDate (value) { + if (!value) return null; + + const text = `${value}`; + const date = new Date(TIMESTAMP_HAS_TIME_ZONE.test(text) ? text : `${text}Z`); + return Number.isNaN(date.getTime()) ? null : date; + } + + #parseNumber (value) { + const number = parseFloat(value); + return Number.isFinite(number) ? number : null; + } + + #convertWeatherType (icon) { + if (!icon) return null; + return WEATHER_ICON_MAP[`${icon}`.toLowerCase()] ?? null; + } +} + +module.exports = BuienradarProvider; diff --git a/tests/unit/modules/default/weather/providers/buienradar_spec.js b/tests/unit/modules/default/weather/providers/buienradar_spec.js new file mode 100644 index 0000000000..25366f7c8e --- /dev/null +++ b/tests/unit/modules/default/weather/providers/buienradar_spec.js @@ -0,0 +1,243 @@ +/** + * Buienradar Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Buienradar covers NL/BE only, metric system, no API key required. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const BUIENRADAR_URL = "https://forecast.buienradar.nl/2.0/forecast/*"; + +/** + * Builds a stable Buienradar mock payload for parsing tests. + * @returns {object} Buienradar forecast response fixture. + */ +function buildBuienradarResponse () { + const today = "2100-01-01"; + const tomorrow = "2100-01-02"; + + const makeHour = (datetime, overrides = {}) => ({ + datetime, + temperature: 5, + feeltemperature: 3, + humidity: 80, + windspeedms: 4, + winddirectiondegrees: 180, + precipitationmm: 0.5, + precipitation: 60, + iconcode: "a", + ...overrides + }); + + return { + location: { + name: "Rotterdam", + lat: 51.92, + lon: 4.48 + }, + days: [ + { + date: today, + sunrise: `${today}T07:00:00`, + sunset: `${today}T17:00:00`, + mintemp: 1, + maxtemp: 8, + humidity: 75, + windspeedms: 5, + winddirectiondegrees: 200, + precipitationmm: 2.5, + precipitation: 70, + iconcode: "b", + hours: [ + makeHour(`${today}T12:00:00`, { temperature: 6, iconcode: "a" }), + makeHour(`${today}T13:00:00`, { temperature: 7, iconcode: "b" }), + makeHour(`${today}T14:00:00`, { temperature: 7.5, iconcode: "c" }) + ] + }, + { + date: tomorrow, + sunrise: `${tomorrow}T07:01:00`, + sunset: `${tomorrow}T17:02:00`, + mintemp: 0, + maxtemp: 6, + windspeedms: 6, + winddirectiondegrees: 220, + precipitationmm: 1.0, + precipitation: 40, + iconcode: "f", + hours: [] + } + ] + }; +} + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("BuienradarProvider", () => { + let BuienradarProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/buienradar"); + BuienradarProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should apply defaults and merge params", () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "hourly" }); + expect(provider.config.apiBase).toBe("https://forecast.buienradar.nl/2.0/forecast"); + expect(provider.config.locationId).toBe(6275); + expect(provider.config.type).toBe("hourly"); + expect(provider.config.updateInterval).toBe(10 * 60 * 1000); + }); + + it("should call error callback when locationId is missing", () => { + const provider = new BuienradarProvider({}); + const onError = vi.fn(); + provider.setCallbacks(vi.fn(), onError); + provider.initialize(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ + translationKey: "MODULE_ERROR_UNSPECIFIED" + })); + expect(provider.fetcher).toBeNull(); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather, set locationName, and merge day metadata", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "current" }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks(resolve, reject); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse())) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(provider.locationName).toBe("Rotterdam"); + expect(typeof result.temperature).toBe("number"); + expect(result.humidity).toBe(80); + expect(result.windSpeed).toBe(4); + expect(result.windFromDirection).toBe(180); + expect(result.minTemperature).toBe(1); + expect(result.maxTemperature).toBe(8); + expect(result.precipitationAmount).toBe(0.5); + expect(result.precipitationProbability).toBe(60); + expect(result.precipitationUnits).toBe("mm"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(typeof result.weatherType).toBe("string"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "forecast", maxEntries: 2 }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse())) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + + expect(result[0].minTemperature).toBe(1); + expect(result[0].maxTemperature).toBe(8); + expect(result[0].humidity).toBe(75); + expect(result[0].windSpeed).toBe(5); + expect(result[0].windFromDirection).toBe(200); + expect(result[0].precipitationAmount).toBe(2.5); + expect(result[0].precipitationProbability).toBe(70); + expect(result[0].sunrise).toBeInstanceOf(Date); + expect(result[0].sunset).toBeInstanceOf(Date); + expect(result[0].weatherType).toBe("day-cloudy"); + + expect(result[1].minTemperature).toBe(0); + expect(result[1].maxTemperature).toBe(6); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "hourly", maxEntries: 3 }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse())) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].temperature).toBe(6); + expect(result[0].feelsLikeTemp).toBe(3); + expect(result[0].humidity).toBe(80); + expect(result[0].windSpeed).toBe(4); + expect(result[0].windFromDirection).toBe(180); + expect(result[0].precipitationAmount).toBe(0.5); + expect(result[0].precipitationProbability).toBe(60); + expect(result[0].weatherType).toBe("day-sunny"); + expect(result[0].date).toBeInstanceOf(Date); + + expect(result[1].weatherType).toBe("day-cloudy"); + expect(result[2].weatherType).toBe("cloudy"); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid API response", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "current" }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json({ days: [] })) + ); + + provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + }); +});