Files
Kristjan ESPERANTO 5d11b5d73b fix: modules losing data after HTTP 304 responses (#4180)
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()`
2026-06-06 20:05:05 +02:00

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;