Files
Kristjan ESPERANTO de78190ed7 fix(weather): fix openmeteo forecast stuck in the past (#4064)
The URL was built once at startup with a hardcoded start_date. Since
HTTPFetcher reuses the same URL, the forecast never advanced past day
one.

Use forecast_days instead — Open-Meteo resolves it relative to today on
every request. Other providers are not affected as they don't use dates
in their URLs.

Fixes #4063
2026-03-23 09:07:39 +01:00

553 lines
17 KiB
JavaScript

const Log = require("logger");
const HTTPFetcher = require("#http_fetcher");
// https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api
const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client";
const OPEN_METEO_BASE = "https://api.open-meteo.com/v1";
/**
* Server-side weather provider for Open-Meteo
* see https://open-meteo.com/
*/
class OpenMeteoProvider {
// https://open-meteo.com/en/docs
hourlyParams = [
"temperature_2m",
"relativehumidity_2m",
"dewpoint_2m",
"apparent_temperature",
"pressure_msl",
"surface_pressure",
"cloudcover",
"cloudcover_low",
"cloudcover_mid",
"cloudcover_high",
"windspeed_10m",
"windspeed_80m",
"windspeed_120m",
"windspeed_180m",
"winddirection_10m",
"winddirection_80m",
"winddirection_120m",
"winddirection_180m",
"windgusts_10m",
"shortwave_radiation",
"direct_radiation",
"direct_normal_irradiance",
"diffuse_radiation",
"vapor_pressure_deficit",
"cape",
"evapotranspiration",
"et0_fao_evapotranspiration",
"precipitation",
"snowfall",
"precipitation_probability",
"rain",
"showers",
"weathercode",
"snow_depth",
"freezinglevel_height",
"visibility",
"soil_temperature_0cm",
"soil_temperature_6cm",
"soil_temperature_18cm",
"soil_temperature_54cm",
"soil_moisture_0_1cm",
"soil_moisture_1_3cm",
"soil_moisture_3_9cm",
"soil_moisture_9_27cm",
"soil_moisture_27_81cm",
"uv_index",
"uv_index_clear_sky",
"is_day",
"terrestrial_radiation",
"terrestrial_radiation_instant",
"shortwave_radiation_instant",
"diffuse_radiation_instant",
"direct_radiation_instant",
"direct_normal_irradiance_instant"
];
dailyParams = [
"temperature_2m_max",
"temperature_2m_min",
"apparent_temperature_min",
"apparent_temperature_max",
"precipitation_sum",
"rain_sum",
"showers_sum",
"snowfall_sum",
"precipitation_hours",
"weathercode",
"sunrise",
"sunset",
"windspeed_10m_max",
"windgusts_10m_max",
"winddirection_10m_dominant",
"shortwave_radiation_sum",
"uv_index_max",
"et0_fao_evapotranspiration"
];
constructor (config) {
this.config = {
apiBase: OPEN_METEO_BASE,
lat: 0,
lon: 0,
pastDays: 0,
type: "current",
maxNumberOfDays: 5,
updateInterval: 10 * 60 * 1000,
...config
};
this.locationName = null;
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
async initialize () {
await this.#fetchLocation();
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"}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data && data.city) {
this.locationName = `${data.city}, ${data.principalSubdivisionCode}`;
}
} catch (error) {
Log.debug("[openmeteo] Could not load location data:", error.message);
}
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: { "Cache-Control": "no-cache" },
logContext: "weatherprovider.openmeteo"
});
this.fetcher.on("response", async (response) => {
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);
}
});
}
#handleResponse (data) {
const parsedData = this.#parseWeatherApiResponse(data);
if (!parsedData) {
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Invalid API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
try {
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateWeatherDayFromCurrentWeather(parsedData);
break;
case "forecast":
case "daily":
weatherData = this.#generateWeatherObjectsFromForecast(parsedData);
break;
case "hourly":
weatherData = this.#generateWeatherObjectsFromHourly(parsedData);
break;
default:
Log.error(`[openmeteo] Unknown type: ${this.config.type}`);
throw new Error(`Unknown weather type: ${this.config.type}`);
}
if (weatherData && this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[openmeteo] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#getQueryParameters () {
let maxNumberOfDays = this.config.maxNumberOfDays;
if (this.config.maxNumberOfDays !== undefined && !isNaN(parseFloat(this.config.maxNumberOfDays))) {
const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0;
const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0;
const maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit));
maxNumberOfDays = Math.ceil(maxEntries / Math.max(1, daysFactor));
}
const params = {
latitude: this.config.lat,
longitude: this.config.lon,
timeformat: "unixtime",
timezone: "auto",
past_days: this.config.pastDays ?? 0,
daily: this.dailyParams,
hourly: this.hourlyParams,
temperature_unit: "celsius",
windspeed_unit: "ms",
precipitation_unit: "mm"
};
switch (this.config.type) {
case "hourly":
case "daily":
case "forecast":
params.forecast_days = maxNumberOfDays + 1; // Open-Meteo counts today as day 1, so maxNumberOfDays=5 needs forecast_days=6
break;
case "current":
params.current_weather = true;
params.forecast_days = 1;
break;
default:
return "";
}
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== "")
.map((key) => {
switch (key) {
case "hourly":
case "daily":
return `${encodeURIComponent(key)}=${params[key].join(",")}`;
default:
return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`;
}
})
.join("&");
}
#getUrl () {
return `${this.config.apiBase}/forecast?${this.#getQueryParameters()}`;
}
#transposeDataMatrix (data) {
return data.time.map((_, index) => Object.keys(data).reduce((row, key) => {
const value = data[key][index];
return {
...row,
// Convert Unix timestamps to Date objects
// timezone: "auto" returns times already in location timezone
[key]: ["time", "sunrise", "sunset"].includes(key) ? new Date(value * 1000) : value
};
}, {}));
}
#parseWeatherApiResponse (data) {
const validByType = {
current: data.current_weather && data.current_weather.time,
hourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0,
daily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0
};
const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type;
if (!validByType[type]) return null;
if (type === "current" && !validByType.daily && !validByType.hourly) {
return null;
}
for (const key of ["hourly", "daily"]) {
if (typeof data[key] === "object") {
data[key] = this.#transposeDataMatrix(data[key]);
}
}
if (data.current_weather) {
data.current_weather.time = new Date(data.current_weather.time * 1000);
}
return data;
}
#convertWeatherType (weathercode, isDayTime) {
const weatherConditions = {
0: "clear",
1: "mainly-clear",
2: "partly-cloudy",
3: "overcast",
45: "fog",
48: "depositing-rime-fog",
51: "drizzle-light-intensity",
53: "drizzle-moderate-intensity",
55: "drizzle-dense-intensity",
56: "freezing-drizzle-light-intensity",
57: "freezing-drizzle-dense-intensity",
61: "rain-slight-intensity",
63: "rain-moderate-intensity",
65: "rain-heavy-intensity",
66: "freezing-rain-light-intensity",
67: "freezing-rain-heavy-intensity",
71: "snow-fall-slight-intensity",
73: "snow-fall-moderate-intensity",
75: "snow-fall-heavy-intensity",
77: "snow-grains",
80: "rain-showers-slight",
81: "rain-showers-moderate",
82: "rain-showers-violent",
85: "snow-showers-slight",
86: "snow-showers-heavy",
95: "thunderstorm",
96: "thunderstorm-slight-hail",
99: "thunderstorm-heavy-hail"
};
if (!(weathercode in weatherConditions)) return null;
const mappings = {
clear: isDayTime ? "day-sunny" : "night-clear",
"mainly-clear": isDayTime ? "day-cloudy" : "night-alt-cloudy",
"partly-cloudy": isDayTime ? "day-cloudy" : "night-alt-cloudy",
overcast: isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy",
fog: isDayTime ? "day-fog" : "night-fog",
"depositing-rime-fog": isDayTime ? "day-fog" : "night-fog",
"drizzle-light-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
"rain-slight-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
"rain-showers-slight": isDayTime ? "day-sprinkle" : "night-sprinkle",
"drizzle-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
"rain-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
"rain-showers-moderate": isDayTime ? "day-showers" : "night-showers",
"drizzle-dense-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"rain-heavy-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"rain-showers-violent": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"freezing-rain-light-intensity": isDayTime ? "day-rain-mix" : "night-rain-mix",
"freezing-drizzle-light-intensity": "snowflake-cold",
"freezing-drizzle-dense-intensity": "snowflake-cold",
"snow-grains": isDayTime ? "day-sleet" : "night-sleet",
"snow-fall-slight-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
"snow-fall-moderate-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
"snow-fall-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
"freezing-rain-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
"snow-showers-slight": isDayTime ? "day-rain-mix" : "night-rain-mix",
"snow-showers-heavy": isDayTime ? "day-rain-mix" : "night-rain-mix",
thunderstorm: isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"thunderstorm-slight-hail": isDayTime ? "day-sleet" : "night-sleet",
"thunderstorm-heavy-hail": isDayTime ? "day-sleet-storm" : "night-sleet-storm"
};
return mappings[weatherConditions[`${weathercode}`]] || "na";
}
#isDayTime (date, sunrise, sunset) {
const time = date.getTime();
return time >= sunrise.getTime() && time < sunset.getTime();
}
#generateWeatherDayFromCurrentWeather (parsedData) {
// Basic current weather data
const current = {
date: parsedData.current_weather.time,
windSpeed: parsedData.current_weather.windspeed,
windFromDirection: parsedData.current_weather.winddirection,
temperature: parsedData.current_weather.temperature,
weatherType: this.#convertWeatherType(parsedData.current_weather.weathercode, true)
};
// Add hourly data if available
if (parsedData.hourly) {
let h;
const currentTime = parsedData.current_weather.time;
// Handle both data shapes: object with arrays or array of objects (after transpose)
if (Array.isArray(parsedData.hourly)) {
// Array of objects (after transpose)
const hourlyIndex = parsedData.hourly.findIndex((hour) => hour.time.getTime() === currentTime.getTime());
h = hourlyIndex !== -1 ? hourlyIndex : 0;
if (hourlyIndex === -1) {
Log.debug("[openmeteo] Could not find current time in hourly data, using index 0");
}
const hourData = parsedData.hourly[h];
if (hourData) {
current.humidity = hourData.relativehumidity_2m;
current.feelsLikeTemp = hourData.apparent_temperature;
current.rain = hourData.rain;
current.snow = hourData.snowfall ? hourData.snowfall * 10 : undefined;
current.precipitationAmount = hourData.precipitation;
current.precipitationProbability = hourData.precipitation_probability;
current.uvIndex = hourData.uv_index;
}
} else if (parsedData.hourly.time) {
// Object with arrays (before transpose - shouldn't happen in normal flow)
const hourlyIndex = parsedData.hourly.time.findIndex((time) => time === currentTime);
h = hourlyIndex !== -1 ? hourlyIndex : 0;
if (hourlyIndex === -1) {
Log.debug("[openmeteo] Could not find current time in hourly data, using index 0");
}
current.humidity = parsedData.hourly.relativehumidity_2m?.[h];
current.feelsLikeTemp = parsedData.hourly.apparent_temperature?.[h];
current.rain = parsedData.hourly.rain?.[h];
current.snow = parsedData.hourly.snowfall?.[h] ? parsedData.hourly.snowfall[h] * 10 : undefined;
current.precipitationAmount = parsedData.hourly.precipitation?.[h];
current.precipitationProbability = parsedData.hourly.precipitation_probability?.[h];
current.uvIndex = parsedData.hourly.uv_index?.[h];
}
}
// Add daily data if available (after transpose, daily is array of objects)
if (parsedData.daily && Array.isArray(parsedData.daily) && parsedData.daily[0]) {
const today = parsedData.daily[0];
if (today.sunrise) {
current.sunrise = today.sunrise;
}
if (today.sunset) {
current.sunset = today.sunset;
// Update weatherType with correct day/night status
if (current.sunrise && current.sunset) {
current.weatherType = this.#convertWeatherType(
parsedData.current_weather.weathercode,
this.#isDayTime(parsedData.current_weather.time, current.sunrise, current.sunset)
);
}
}
if (today.temperature_2m_min !== undefined) {
current.minTemperature = today.temperature_2m_min;
}
if (today.temperature_2m_max !== undefined) {
current.maxTemperature = today.temperature_2m_max;
}
}
return current;
}
#generateWeatherObjectsFromForecast (parsedData) {
return parsedData.daily.map((weather) => ({
date: weather.time,
windSpeed: weather.windspeed_10m_max,
windFromDirection: weather.winddirection_10m_dominant,
sunrise: weather.sunrise,
sunset: weather.sunset,
temperature: parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2),
minTemperature: parseFloat(weather.temperature_2m_min),
maxTemperature: parseFloat(weather.temperature_2m_max),
weatherType: this.#convertWeatherType(weather.weathercode, true),
rain: weather.rain_sum != null ? parseFloat(weather.rain_sum) : null,
snow: weather.snowfall_sum != null ? parseFloat(weather.snowfall_sum * 10) : null,
precipitationAmount: weather.precipitation_sum != null ? parseFloat(weather.precipitation_sum) : null,
precipitationProbability: weather.precipitation_hours != null ? parseFloat(weather.precipitation_hours * 100 / 24) : null,
uvIndex: weather.uv_index_max != null ? parseFloat(weather.uv_index_max) : null
}));
}
#generateWeatherObjectsFromHourly (parsedData) {
const hours = [];
const now = new Date();
parsedData.hourly.forEach((weather, i) => {
// Skip past entries
if (weather.time <= now) {
return;
}
// Calculate daily index with bounds check
const h = Math.ceil((i + 1) / 24) - 1;
const safeH = Math.max(0, Math.min(h, parsedData.daily.length - 1));
const dailyData = parsedData.daily[safeH];
const hourlyWeather = {
date: weather.time,
windSpeed: weather.windspeed_10m,
windFromDirection: weather.winddirection_10m,
sunrise: dailyData.sunrise,
sunset: dailyData.sunset,
temperature: parseFloat(weather.temperature_2m),
minTemperature: parseFloat(dailyData.temperature_2m_min),
maxTemperature: parseFloat(dailyData.temperature_2m_max),
weatherType: this.#convertWeatherType(
weather.weathercode,
this.#isDayTime(weather.time, dailyData.sunrise, dailyData.sunset)
),
humidity: weather.relativehumidity_2m != null ? parseFloat(weather.relativehumidity_2m) : null,
rain: weather.rain != null ? parseFloat(weather.rain) : null,
snow: weather.snowfall != null ? parseFloat(weather.snowfall * 10) : null,
precipitationAmount: weather.precipitation != null ? parseFloat(weather.precipitation) : null,
precipitationProbability: weather.precipitation_probability != null ? parseFloat(weather.precipitation_probability) : null,
uvIndex: weather.uv_index != null ? parseFloat(weather.uv_index) : null
};
hours.push(hourlyWeather);
});
return hours;
}
}
module.exports = OpenMeteoProvider;