mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-06-14 04:30:02 +00:00
When a server responds with 304 (nothing changed since last fetch), the response has no body. Several modules were trying to parse that empty body anyway - which either cleared their cached data or threw an exception. The result: a blank calendar, empty newsfeed, or missing weather data after the next refresh cycle. This was reported in the forum: https://forum.magicmirror.builders/topic/20250/calendar-events-broadcasting-nothing-showing The bug was "introduced" by #4120, which correctly started forwarding 304s to consumers - but not all were ready for it. ### Fix Skip parsing on 304 and keep the existing data as-is: - **calendar** - re-broadcasts cached events - **newsfeed** - re-broadcasts cached items - **buienradar, openmeteo, weatherflow, weathergov** - return early before calling `response.json()`
350 lines
8.8 KiB
JavaScript
350 lines
8.8 KiB
JavaScript
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) => {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
#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;
|