feat(weather): add Buienradar provider (#4164)

This adds the weather provider Buienradar for the Netherlands and
Belgium without requiring an API key.

In MagicMirrorOrg/MagicMirror-Documentation#380 @plebcity shared his
implementation for the old browser-side architecture. I used it as a
reference to build this server-side implementation.

### Example screenshot

<img width="969" height="578" alt="Ekrankopio de 2026-05-20 20-38-36"
src="https://github.com/user-attachments/assets/56623ad8-7439-4047-abad-452ba2ebdcb2"
/>

### Example config

```js
		{
			module: "weather",
			position: "top_left",
			header: "Buienradar - Current",
			config: {
				weatherProvider: "buienradar",
				type: "current",
				locationId: 2747891
			}
		},
		{
			module: "weather",
			position: "top_right",
			header: "Buienradar - Forecast",
			config: {
				weatherProvider: "buienradar",
				type: "forecast",
				locationId: 2747891
			}
		},
		{
			module: "weather",
			position: "bottom_left",
			header: "Buienradar - Hourly",
			config: {
				weatherProvider: "buienradar",
				type: "hourly",
				locationId: 2747891
			}
		},
```

----

When this is accepted and merged I'll update the weather docs.
This commit is contained in:
Kristjan ESPERANTO
2026-05-20 22:06:44 +02:00
committed by GitHub
parent 9a3538de01
commit fd687bf5fe
3 changed files with 599 additions and 7 deletions
+8 -7
View File
@@ -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",
@@ -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;
@@ -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");
});
});
});