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;