mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-07-06 06:12:26 -07:00
4b4a59534f
## Release Notes Thanks to: @angeldeejay, @egeekial, @khassel, @KristjanESPERANTO, @MikeBishop, @rejas > ⚠️ This release needs nodejs version >=22.21.1 <23 || >=24 (no change to previous release) [Compare to previous Release v2.36.0](https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.36.0...develop) ### [core] - Prepare Release 2.37.0 (#4193) - fix(electron): map IPv6 :: wildcard to localhost (#4188) - refactor(main): modernize DOM update flow with async/await (#4186) - refactor(main): simplify _updateDom with async/await (#4185) - fix(security): prevent unauthorized secret expansion in socket payloads (#4184) - refactor(main): simplify updateDomWithContent async flow (#4182) - fix: modules losing data after HTTP 304 responses (#4180) - chore: add missing core defaults (#4181) - fix(server): enforce ipWhitelist for Socket.IO too (#4169) - feat(systeminfo): include Git hash and branch in system information log (#4167) - feat(electron): support object-based electronSwitches (#4161) - systeminformation thread not ending: move error handling from utils to app (#4160) - fix systeminformation thread not ending (#4155) - refactor: use ES module imports in browser core (#4158) - refactor(core): remove old Object.assign polyfill (#4157) - refactor: rewrite Module as an ES6 class (#4151) - refactor: rewrite NodeHelper as an ES6 class (#4147) - update eletron to v42 (#4144) - refactor(utils): drop ajv dependency (#4142) - fix(systeminformation): output right 'used node' version (from parent process) (#4141) - fix: skip postinstall git clean when not in a git repository (#4139) - Remove unnecessary conditionals and fix falsy property check in imperial conversion (#4135) - update version in package.json ### [dependencies] - update dependencies (#4191) - Bump actions/checkout from 6 to 7 (#4190) - chore: update dependencies and adjust import path for SunCalc (#4189) - update dependencies incl. electron and revert yauzl-electron-install-fix (#4183) - update dependencies, add electron fix in package.json (#4175) - chore: update dependencies (#4162) - Bump actions/dependency-review-action from 4 to 5 (#4152) - Unify linting: replace Stylelint and markdownlint with ESLint (#4148) - update dependencies and workflows to node v26 (#4140) ### [modules/alert] - CodeQL cleanup for alerts #18, #19, #20 (#4153) - fix: resolve CodeQL alerts #24 and #26 (#4145) - fix(electron): resolve CodeQL alerts #22 and #25 in electron.js (#4136) ### [modules/calendar] - perf(calendar): pre-filter ICS data before parsing (#4168) - perf(calendar): use async ICS parsing to avoid blocking event loop (#4143) ### [modules/newsfeed] - [newsfeed] add allowBasicHtmlTags option for basic emphasis (#4176) ### [modules/updatenotification] - fix(updatenotification): don't spawn a child process when running under PM2 (#4166) - fix(updatenotification): use process.argv[0] as restart binary (#4163) - fix(updatenotification): preserve start mode on restart (#4156) - fix(updatenotification): fix ref diff parsing for fetch --dry-run (#4138) - refactor(updatenotification): replace pm2 usage with node logic (#4134) ### [modules/weather] - feat(weather): add Buienradar provider (#4164) ### [testing] - remove warning in unit tests (for nodejs >= v25) (#4149) - polish HTTP 304 docs/test/handling (#4129) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com> Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com> Co-authored-by: Karsten Hassel <hassel@gmx.de> Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com> Co-authored-by: Koen Konst <koenspero@gmail.com> Co-authored-by: Koen Konst <c.h.konst@avisi.nl> Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr> Co-authored-by: dathbe <github@beffa.us> Co-authored-by: veeck <gitkraken@veeck.de> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marcel <m-idler@users.noreply.github.com> Co-authored-by: sam detweiler <sdetweil@gmail.com> Co-authored-by: Kevin G. <crazylegstoo@gmail.com> Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com> Co-authored-by: Jboucly <contact@jboucly.fr> Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com> Co-authored-by: Jordan Welch <JordanHWelch@gmail.com> Co-authored-by: Blackspirits <blackspirits@gmail.com> Co-authored-by: Samed Ozdemir <samed@xsor.io> Co-authored-by: in-voker <58696565+in-voker@users.noreply.github.com> Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com> Co-authored-by: cgillinger <christian.gillinger@gmail.com> Co-authored-by: Sonny B <43247590+sonnyb9@users.noreply.github.com> Co-authored-by: sonnyb9 <sonnyb9@users.noreply.github.com> Co-authored-by: Morgan McBee <egeekial@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Mike Bishop <mbishop@evequefou.be>
408 lines
12 KiB
JavaScript
408 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) => {
|
|
if (response.status === 304) return;
|
|
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;
|