mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-04-24 06:47:07 +00:00
After the big weather refactor (#4032), OpenWeatherMap was effectively hard-wired to One Call v3. One Call 2.5 is deprecated and no longer available, so it looked like v2.5 support was effectively over — but the classic `/weather` and `/forecast` endpoints were never actually dropped. This restores support for those. Fixes #4100. ## What this PR does - handles OpenWeatherMap responses by endpoint again (`/onecall`, `/weather`, `/forecast`) - restores v2.5 current and forecast support (including hourly via 3-hour forecast slots) - filters outdated hourly entries centrally while keeping the current hour visible (if available) ## Screenshot <img width="768" height="481" alt="bildo" src="https://github.com/user-attachments/assets/9bce3531-3731-4fd7-b41e-e20603afa725" />
407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
const Log = require("logger");
|
|
const weatherUtils = require("../provider-utils");
|
|
const HTTPFetcher = require("#http_fetcher");
|
|
|
|
/**
|
|
* Server-side weather provider for OpenWeatherMap
|
|
* see https://openweathermap.org/
|
|
*/
|
|
class OpenWeatherMapProvider {
|
|
constructor (config) {
|
|
this.config = {
|
|
apiVersion: "3.0",
|
|
apiBase: "https://api.openweathermap.org/data/",
|
|
weatherEndpoint: "/onecall",
|
|
locationID: false,
|
|
location: false,
|
|
lat: 0,
|
|
lon: 0,
|
|
apiKey: "",
|
|
type: "current",
|
|
updateInterval: 10 * 60 * 1000,
|
|
...config
|
|
};
|
|
|
|
this.fetcher = null;
|
|
this.onDataCallback = null;
|
|
this.onErrorCallback = null;
|
|
this.locationName = null;
|
|
}
|
|
|
|
initialize () {
|
|
// Validate callbacks exist
|
|
if (typeof this.onErrorCallback !== "function") {
|
|
throw new Error("setCallbacks() must be called before initialize()");
|
|
}
|
|
|
|
if (!this.config.apiKey) {
|
|
Log.error("[openweathermap] API key is required");
|
|
this.onErrorCallback({
|
|
message: "API key is required",
|
|
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
|
});
|
|
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 () {
|
|
const url = this.#getUrl();
|
|
|
|
this.fetcher = new HTTPFetcher(url, {
|
|
reloadInterval: this.config.updateInterval,
|
|
headers: { "Cache-Control": "no-cache" },
|
|
logContext: "weatherprovider.openweathermap"
|
|
});
|
|
|
|
this.fetcher.on("response", async (response) => {
|
|
try {
|
|
const data = await response.json();
|
|
this.#handleResponse(data);
|
|
} catch (error) {
|
|
Log.error("[openweathermap] 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) {
|
|
try {
|
|
let weatherData;
|
|
|
|
if (this.config.weatherEndpoint === "/onecall") {
|
|
// One Call API (v3.0)
|
|
if (data.timezone) {
|
|
this.locationName = data.timezone;
|
|
}
|
|
|
|
const onecallData = this.#generateWeatherObjectsFromOnecall(data);
|
|
|
|
switch (this.config.type) {
|
|
case "current":
|
|
weatherData = onecallData.current;
|
|
break;
|
|
case "forecast":
|
|
case "daily":
|
|
weatherData = onecallData.days;
|
|
break;
|
|
case "hourly":
|
|
weatherData = onecallData.hours;
|
|
break;
|
|
default:
|
|
Log.error(`[openweathermap] Unknown type: ${this.config.type}`);
|
|
throw new Error(`Unknown weather type: ${this.config.type}`);
|
|
}
|
|
} else if (this.config.weatherEndpoint === "/weather") {
|
|
// Current weather endpoint (API v2.5)
|
|
weatherData = this.#generateWeatherObjectFromCurrentWeather(data);
|
|
} else if (this.config.weatherEndpoint === "/forecast") {
|
|
// 3-hourly forecast endpoint (API v2.5)
|
|
weatherData = this.config.type === "hourly"
|
|
? this.#generateHourlyWeatherObjectsFromForecast(data)
|
|
: this.#generateDailyWeatherObjectsFromForecast(data);
|
|
} else {
|
|
throw new Error(`Unknown weather endpoint: ${this.config.weatherEndpoint}`);
|
|
}
|
|
|
|
if (weatherData && this.onDataCallback) {
|
|
this.onDataCallback(weatherData);
|
|
}
|
|
} catch (error) {
|
|
Log.error("[openweathermap] Error processing weather data:", error);
|
|
if (this.onErrorCallback) {
|
|
this.onErrorCallback({
|
|
message: error.message,
|
|
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#generateWeatherObjectFromCurrentWeather (data) {
|
|
const timezoneOffsetMinutes = (data.timezone ?? 0) / 60;
|
|
|
|
if (data.name && data.sys?.country) {
|
|
this.locationName = `${data.name}, ${data.sys.country}`;
|
|
} else if (data.name) {
|
|
this.locationName = data.name;
|
|
}
|
|
|
|
const weather = {};
|
|
weather.date = weatherUtils.applyTimezoneOffset(new Date(data.dt * 1000), timezoneOffsetMinutes);
|
|
weather.temperature = data.main.temp;
|
|
weather.feelsLikeTemp = data.main.feels_like;
|
|
weather.humidity = data.main.humidity;
|
|
weather.windSpeed = data.wind.speed;
|
|
weather.windFromDirection = data.wind.deg;
|
|
weather.weatherType = weatherUtils.convertWeatherType(data.weather[0].icon);
|
|
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunrise * 1000), timezoneOffsetMinutes);
|
|
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunset * 1000), timezoneOffsetMinutes);
|
|
|
|
return weather;
|
|
}
|
|
|
|
#extractThreeHourPrecipitation (forecast) {
|
|
const rain = Number.parseFloat(forecast.rain?.["3h"] ?? "") || 0;
|
|
const snow = Number.parseFloat(forecast.snow?.["3h"] ?? "") || 0;
|
|
const precipitationAmount = rain + snow;
|
|
|
|
return {
|
|
rain,
|
|
snow,
|
|
precipitationAmount,
|
|
hasPrecipitation: precipitationAmount > 0
|
|
};
|
|
}
|
|
|
|
#generateHourlyWeatherObjectsFromForecast (data) {
|
|
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
|
|
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
|
|
|
|
if (data.city?.name && data.city?.country) {
|
|
this.locationName = `${data.city.name}, ${data.city.country}`;
|
|
}
|
|
|
|
return data.list.map((forecast) => {
|
|
const weather = {};
|
|
weather.date = weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes);
|
|
weather.temperature = forecast.main.temp;
|
|
weather.feelsLikeTemp = forecast.main.feels_like;
|
|
weather.humidity = forecast.main.humidity;
|
|
weather.windSpeed = forecast.wind.speed;
|
|
weather.windFromDirection = forecast.wind.deg;
|
|
weather.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
|
|
weather.precipitationProbability = forecast.pop !== undefined ? forecast.pop * 100 : undefined;
|
|
|
|
const precipitation = this.#extractThreeHourPrecipitation(forecast);
|
|
if (precipitation.hasPrecipitation) {
|
|
weather.rain = precipitation.rain;
|
|
weather.snow = precipitation.snow;
|
|
weather.precipitationAmount = precipitation.precipitationAmount;
|
|
}
|
|
|
|
return weather;
|
|
});
|
|
}
|
|
|
|
#generateDailyWeatherObjectsFromForecast (data) {
|
|
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
|
|
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
|
|
|
|
if (data.city?.name && data.city?.country) {
|
|
this.locationName = `${data.city.name}, ${data.city.country}`;
|
|
}
|
|
|
|
const dayMap = new Map();
|
|
|
|
for (const forecast of data.list) {
|
|
// Shift dt by timezone offset so UTC fields represent local time
|
|
const localDate = new Date((forecast.dt + timezoneOffsetSeconds) * 1000);
|
|
const dateKey = `${localDate.getUTCFullYear()}-${String(localDate.getUTCMonth() + 1).padStart(2, "0")}-${String(localDate.getUTCDate()).padStart(2, "0")}`;
|
|
|
|
if (!dayMap.has(dateKey)) {
|
|
dayMap.set(dateKey, {
|
|
date: weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes),
|
|
minTemps: [],
|
|
maxTemps: [],
|
|
rain: 0,
|
|
snow: 0,
|
|
weatherType: weatherUtils.convertWeatherType(forecast.weather[0].icon)
|
|
});
|
|
}
|
|
|
|
const day = dayMap.get(dateKey);
|
|
day.minTemps.push(forecast.main.temp_min);
|
|
day.maxTemps.push(forecast.main.temp_max);
|
|
|
|
const hour = localDate.getUTCHours();
|
|
if (hour >= 8 && hour <= 17) {
|
|
day.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
|
|
}
|
|
|
|
const precipitation = this.#extractThreeHourPrecipitation(forecast);
|
|
day.rain += precipitation.rain;
|
|
day.snow += precipitation.snow;
|
|
}
|
|
|
|
return Array.from(dayMap.values()).map((day) => ({
|
|
date: day.date,
|
|
minTemperature: Math.min(...day.minTemps),
|
|
maxTemperature: Math.max(...day.maxTemps),
|
|
weatherType: day.weatherType,
|
|
rain: day.rain,
|
|
snow: day.snow,
|
|
precipitationAmount: day.rain + day.snow
|
|
}));
|
|
}
|
|
|
|
#generateWeatherObjectsFromOnecall (data) {
|
|
let precip;
|
|
|
|
// Get current weather
|
|
const current = {};
|
|
if (data.hasOwnProperty("current")) {
|
|
const timezoneOffset = data.timezone_offset / 60;
|
|
current.date = weatherUtils.applyTimezoneOffset(new Date(data.current.dt * 1000), timezoneOffset);
|
|
current.windSpeed = data.current.wind_speed;
|
|
current.windFromDirection = data.current.wind_deg;
|
|
current.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.current.sunrise * 1000), timezoneOffset);
|
|
current.sunset = weatherUtils.applyTimezoneOffset(new Date(data.current.sunset * 1000), timezoneOffset);
|
|
current.temperature = data.current.temp;
|
|
current.weatherType = weatherUtils.convertWeatherType(data.current.weather[0].icon);
|
|
current.humidity = data.current.humidity;
|
|
current.uvIndex = data.current.uvi;
|
|
|
|
precip = false;
|
|
if (data.current.hasOwnProperty("rain") && !isNaN(data.current.rain["1h"])) {
|
|
current.rain = data.current.rain["1h"];
|
|
precip = true;
|
|
}
|
|
if (data.current.hasOwnProperty("snow") && !isNaN(data.current.snow["1h"])) {
|
|
current.snow = data.current.snow["1h"];
|
|
precip = true;
|
|
}
|
|
if (precip) {
|
|
current.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0);
|
|
}
|
|
current.feelsLikeTemp = data.current.feels_like;
|
|
}
|
|
|
|
// Get hourly weather
|
|
const hours = [];
|
|
if (data.hasOwnProperty("hourly")) {
|
|
const timezoneOffset = data.timezone_offset / 60;
|
|
for (const hour of data.hourly) {
|
|
const weather = {};
|
|
weather.date = weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset);
|
|
weather.temperature = hour.temp;
|
|
weather.feelsLikeTemp = hour.feels_like;
|
|
weather.humidity = hour.humidity;
|
|
weather.windSpeed = hour.wind_speed;
|
|
weather.windFromDirection = hour.wind_deg;
|
|
weather.weatherType = weatherUtils.convertWeatherType(hour.weather[0].icon);
|
|
weather.precipitationProbability = hour.pop !== undefined ? hour.pop * 100 : undefined;
|
|
weather.uvIndex = hour.uvi;
|
|
|
|
precip = false;
|
|
if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) {
|
|
weather.rain = hour.rain["1h"];
|
|
precip = true;
|
|
}
|
|
if (hour.hasOwnProperty("snow") && !isNaN(hour.snow["1h"])) {
|
|
weather.snow = hour.snow["1h"];
|
|
precip = true;
|
|
}
|
|
if (precip) {
|
|
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
|
|
}
|
|
|
|
hours.push(weather);
|
|
}
|
|
}
|
|
|
|
// Get daily weather
|
|
const days = [];
|
|
if (data.hasOwnProperty("daily")) {
|
|
const timezoneOffset = data.timezone_offset / 60;
|
|
for (const day of data.daily) {
|
|
const weather = {};
|
|
weather.date = weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset);
|
|
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(day.sunrise * 1000), timezoneOffset);
|
|
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(day.sunset * 1000), timezoneOffset);
|
|
weather.minTemperature = day.temp.min;
|
|
weather.maxTemperature = day.temp.max;
|
|
weather.humidity = day.humidity;
|
|
weather.windSpeed = day.wind_speed;
|
|
weather.windFromDirection = day.wind_deg;
|
|
weather.weatherType = weatherUtils.convertWeatherType(day.weather[0].icon);
|
|
weather.precipitationProbability = day.pop !== undefined ? day.pop * 100 : undefined;
|
|
weather.uvIndex = day.uvi;
|
|
|
|
precip = false;
|
|
if (!isNaN(day.rain)) {
|
|
weather.rain = day.rain;
|
|
precip = true;
|
|
}
|
|
if (!isNaN(day.snow)) {
|
|
weather.snow = day.snow;
|
|
precip = true;
|
|
}
|
|
if (precip) {
|
|
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
|
|
}
|
|
|
|
days.push(weather);
|
|
}
|
|
}
|
|
|
|
return { current, hours, days };
|
|
}
|
|
|
|
#getUrl () {
|
|
return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.#getParams();
|
|
}
|
|
|
|
#getParams () {
|
|
let params = "?";
|
|
|
|
if (this.config.weatherEndpoint === "/onecall") {
|
|
params += `lat=${this.config.lat}`;
|
|
params += `&lon=${this.config.lon}`;
|
|
|
|
if (this.config.type === "current") {
|
|
params += "&exclude=minutely,hourly,daily";
|
|
} else if (this.config.type === "hourly") {
|
|
params += "&exclude=current,minutely,daily";
|
|
} else if (this.config.type === "daily" || this.config.type === "forecast") {
|
|
params += "&exclude=current,minutely,hourly";
|
|
} else {
|
|
params += "&exclude=minutely";
|
|
}
|
|
} else if (this.config.lat && this.config.lon) {
|
|
params += `lat=${this.config.lat}&lon=${this.config.lon}`;
|
|
} else if (this.config.locationID) {
|
|
params += `id=${this.config.locationID}`;
|
|
} else if (this.config.location) {
|
|
params += `q=${this.config.location}`;
|
|
}
|
|
|
|
params += "&units=metric";
|
|
params += `&lang=${this.config.lang || "en"}`;
|
|
params += `&APPID=${this.config.apiKey}`;
|
|
|
|
return params;
|
|
}
|
|
}
|
|
|
|
module.exports = OpenWeatherMapProvider;
|