mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-04-29 17:23:11 +00:00
This migrates the Weather module from client-side fetching to use the server-side centralized HTTPFetcher (introduced in #4016), following the same pattern as the Calendar and Newsfeed modules. ## Motivation This brings consistent error handling and better maintainability and completes the refactoring effort to centralize HTTP error handling across all default modules. Migrating to server-side providers with HTTPFetcher brings: - **Centralized error handling**: Inherits smart retry strategies (401/403, 429, 5xx backoff) and timeout handling (30s) - **Consistency**: Same architecture as Calendar and Newsfeed modules - **Security**: Possibility to hide API keys/secrets from client-side - **Performance**: Reduced API calls in multi-client setups - one server fetch instead of one per client - **Enabling possible future features**: e.g. server-side caching, rate limit monitoring, and data sharing with third-party modules ## Changes - All 10 weather providers now use HTTPFetcher for server-side fetching - Consistent error handling like Calendar and Newsfeed modules ## Breaking Changes None. Existing configurations continue to work. ## Testing To ensure proper functionality, I obtained API keys and credentials for all providers that require them. I configured all 10 providers in a carousel setup and tested each one individually. Screenshots for each provider are attached below demonstrating their working state. I even requested developer access from the Tempest/WeatherFlow team to properly test this provider. **Comprehensive test coverage**: A major advantage of the server-side architecture is the ability to thoroughly test providers with unit tests using real API response snapshots. Don't be alarmed by the many lines added in this PR - they are primarily test files and real-data mocks that ensure provider reliability. ## Review Notes I know this is an enormous change - I've been working on this for quite some time. Unfortunately, breaking it into smaller incremental PRs wasn't feasible due to the interdependencies between providers and the shared architecture. Given the scope, it's nearly impossible to manually review every change. To ensure quality, I've used both CodeRabbit and GitHub Copilot to review the code multiple times in my fork, and both provided extensive and valuable feedback. Most importantly, my test setup with all 10 providers working successfully is very encouraging. ## Related Part of the HTTPFetcher migration #4016. ## Screenshots <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-06-54" src="https://github.com/user-attachments/assets/2139f4d2-2a9b-4e49-8d0a-e4436983ed6e" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-02" src="https://github.com/user-attachments/assets/880f7ce2-4e44-42d5-bfe4-5ce475cca7c2" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-07" src="https://github.com/user-attachments/assets/abd89933-fe03-40ab-8a7c-41ae1ff99255" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-12" src="https://github.com/user-attachments/assets/22225852-f0a9-4d33-87ab-0733ba30fad3" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-17" src="https://github.com/user-attachments/assets/7a7192a5-f237-4060-85d7-6f50b9bef5af" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-22" src="https://github.com/user-attachments/assets/df84d9f1-e531-4995-8da8-d6f2601b6a08" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-27" src="https://github.com/user-attachments/assets/4cf391ac-db43-4b52-95f4-f5eadc5ea34d" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-32" src="https://github.com/user-attachments/assets/8dd8e688-d47f-4815-87f6-7f2630f15d58" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-37" src="https://github.com/user-attachments/assets/ee84a8bc-6b35-405a-b311-88658d9268dd" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-42" src="https://github.com/user-attachments/assets/f941f341-453f-4d4d-a8d9-6b9158eb2681" /> Provider "Weather API" added later: <img width="1910" height="1080" alt="Ekrankopio de 2026-02-15 19-39-06" src="https://github.com/user-attachments/assets/3f0c8ba3-105c-4f90-8b2e-3a1be543d3d2" />
383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
/* global WeatherProvider, WeatherUtils, WeatherObject, formatTime */
|
|
|
|
Module.register("weather", {
|
|
// Default module config.
|
|
defaults: {
|
|
weatherProvider: "openweathermap",
|
|
roundTemp: false,
|
|
type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint)
|
|
lang: config.language,
|
|
units: config.units,
|
|
tempUnits: config.units,
|
|
windUnits: config.units,
|
|
timeFormat: config.timeFormat,
|
|
updateInterval: 10 * 60 * 1000, // every 10 minutes
|
|
animationSpeed: 1000,
|
|
showFeelsLike: true,
|
|
showHumidity: "none", // possible options for "current" weather are "none", "wind", "temp", "feelslike" or "below", for "hourly" weather "none" or "true"
|
|
hideZeroes: false, // hide zeroes (and empty columns) in hourly, currently only for precipitation
|
|
showIndoorHumidity: false,
|
|
showIndoorTemperature: false,
|
|
allowOverrideNotification: false,
|
|
showPeriod: true,
|
|
showPeriodUpper: false,
|
|
showPrecipitationAmount: false,
|
|
showPrecipitationProbability: false,
|
|
showUVIndex: false,
|
|
showSun: true,
|
|
showWindDirection: true,
|
|
showWindDirectionAsArrow: false,
|
|
degreeLabel: false,
|
|
decimalSymbol: ".",
|
|
maxNumberOfDays: 5,
|
|
maxEntries: 5,
|
|
ignoreToday: false,
|
|
fade: true,
|
|
fadePoint: 0.25, // Start on 1/4th of the list.
|
|
initialLoadDelay: 0, // 0 seconds delay
|
|
appendLocationNameToHeader: true,
|
|
calendarClass: "calendar",
|
|
tableClass: "small",
|
|
onlyTemp: false,
|
|
colored: false,
|
|
absoluteDates: false,
|
|
forecastDateFormat: "ddd", // format for forecast date display, e.g., "ddd" = Mon, "dddd" = Monday, "D MMM" = 18 Oct
|
|
hourlyForecastIncrements: 1
|
|
},
|
|
|
|
// Module properties (all providers run server-side)
|
|
instanceId: null,
|
|
fetchedLocationName: null,
|
|
currentWeatherObject: null,
|
|
weatherForecastArray: null,
|
|
weatherHourlyArray: null,
|
|
|
|
// Can be used by the provider to display location of event if nothing else is specified
|
|
firstEvent: null,
|
|
|
|
// Define required scripts.
|
|
getStyles () {
|
|
return ["font-awesome.css", "weather-icons.css", "weather.css"];
|
|
},
|
|
|
|
// Return the scripts that are necessary for the weather module.
|
|
getScripts () {
|
|
// Only load client-side dependencies for rendering
|
|
// All providers run server-side via node_helper
|
|
return ["moment.js", "weatherutils.js", "weatherobject.js", "suncalc.js"];
|
|
},
|
|
|
|
// Override getHeader method.
|
|
getHeader () {
|
|
if (this.config.appendLocationNameToHeader) {
|
|
const locationName = this.fetchedLocationName || "";
|
|
|
|
if (this.data.header && locationName) return `${this.data.header} ${locationName}`;
|
|
else if (locationName) return locationName;
|
|
}
|
|
|
|
return this.data.header ? this.data.header : "";
|
|
},
|
|
|
|
// Start the weather module.
|
|
start () {
|
|
moment.locale(this.config.lang);
|
|
|
|
if (this.config.useKmh) {
|
|
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useKmh'. Please switch to windUnits!");
|
|
this.windUnits = "kmh";
|
|
} else if (this.config.useBeaufort) {
|
|
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!");
|
|
this.windUnits = "beaufort";
|
|
}
|
|
if (typeof this.config.showHumidity === "boolean") {
|
|
Log.warn("[weather] Deprecation warning: Please consider updating showHumidity to the new style (config string).");
|
|
this.config.showHumidity = this.config.showHumidity ? "wind" : "none";
|
|
}
|
|
|
|
// All providers run server-side: generate unique instance ID and initialize via node_helper
|
|
this.instanceId = `${this.identifier}_${Date.now()}`;
|
|
|
|
Log.log(`[weather] Initializing server-side provider with instance ID: ${this.instanceId}`);
|
|
|
|
this.sendSocketNotification("INIT_WEATHER", {
|
|
instanceId: this.instanceId,
|
|
weatherProvider: this.config.weatherProvider,
|
|
...this.config
|
|
});
|
|
|
|
// Server-driven fetching - no client-side scheduling needed
|
|
|
|
// Add custom filters
|
|
this.addFilters();
|
|
},
|
|
|
|
// Cleanup on module hide/suspend
|
|
stop () {
|
|
if (this.instanceId) {
|
|
this.sendSocketNotification("STOP_WEATHER", {
|
|
instanceId: this.instanceId
|
|
});
|
|
}
|
|
},
|
|
|
|
// Override notification handler.
|
|
notificationReceived (notification, payload, sender) {
|
|
if (notification === "CALENDAR_EVENTS") {
|
|
const senderClasses = sender.data.classes.toLowerCase().split(" ");
|
|
if (senderClasses.indexOf(this.config.calendarClass.toLowerCase()) !== -1) {
|
|
this.firstEvent = null;
|
|
for (let event of payload) {
|
|
if (event.location || event.geo) {
|
|
this.firstEvent = event;
|
|
Log.debug("[weather] First upcoming event with location: ", event);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (notification === "INDOOR_TEMPERATURE") {
|
|
this.indoorTemperature = this.roundValue(payload);
|
|
this.updateDom(300);
|
|
} else if (notification === "INDOOR_HUMIDITY") {
|
|
this.indoorHumidity = this.roundValue(payload);
|
|
this.updateDom(300);
|
|
} else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) {
|
|
// Override current weather with data from local sensors
|
|
if (this.currentWeatherObject) {
|
|
Object.assign(this.currentWeatherObject, payload);
|
|
this.updateDom(this.config.animationSpeed);
|
|
}
|
|
}
|
|
},
|
|
|
|
// Handle socket notifications from node_helper
|
|
socketNotificationReceived (notification, payload) {
|
|
if (payload.instanceId !== this.instanceId) {
|
|
return;
|
|
}
|
|
|
|
if (notification === "WEATHER_INITIALIZED") {
|
|
Log.log(`[weather] Provider initialized, location: ${payload.locationName}`);
|
|
this.fetchedLocationName = payload.locationName;
|
|
this.updateDom();
|
|
// Server-driven fetching - HTTPFetcher will send WEATHER_DATA automatically
|
|
} else if (notification === "WEATHER_DATA") {
|
|
this.handleWeatherData(payload);
|
|
} else if (notification === "WEATHER_ERROR") {
|
|
Log.error("[weather] Error from node_helper:", payload.error);
|
|
}
|
|
},
|
|
|
|
handleWeatherData (payload) {
|
|
const { type, data } = payload;
|
|
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
// Convert plain objects to WeatherObject instances
|
|
switch (type) {
|
|
case "current":
|
|
this.currentWeatherObject = this.createWeatherObject(data);
|
|
break;
|
|
case "forecast":
|
|
case "daily":
|
|
this.weatherForecastArray = data.map((d) => this.createWeatherObject(d));
|
|
break;
|
|
case "hourly":
|
|
this.weatherHourlyArray = data.map((d) => this.createWeatherObject(d));
|
|
break;
|
|
default:
|
|
Log.warn(`Unknown weather data type: ${type}`);
|
|
break;
|
|
}
|
|
|
|
this.updateAvailable();
|
|
},
|
|
|
|
createWeatherObject (data) {
|
|
const weather = new WeatherObject();
|
|
Object.assign(weather, {
|
|
...data,
|
|
// Convert to moment objects for template compatibility
|
|
date: data.date ? moment(data.date) : null,
|
|
sunrise: data.sunrise ? moment(data.sunrise) : null,
|
|
sunset: data.sunset ? moment(data.sunset) : null
|
|
});
|
|
return weather;
|
|
},
|
|
|
|
// Select the template depending on the display type.
|
|
getTemplate () {
|
|
switch (this.config.type.toLowerCase()) {
|
|
case "current":
|
|
return "current.njk";
|
|
case "hourly":
|
|
return "hourly.njk";
|
|
case "daily":
|
|
case "forecast":
|
|
return "forecast.njk";
|
|
//Make the invalid values use the "Loading..." from forecast
|
|
default:
|
|
return "forecast.njk";
|
|
}
|
|
},
|
|
|
|
// Add all the data to the template.
|
|
getTemplateData () {
|
|
const hourlyData = this.weatherHourlyArray?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1);
|
|
|
|
return {
|
|
config: this.config,
|
|
current: this.currentWeatherObject,
|
|
forecast: this.weatherForecastArray,
|
|
hourly: hourlyData,
|
|
indoor: {
|
|
humidity: this.indoorHumidity,
|
|
temperature: this.indoorTemperature
|
|
}
|
|
};
|
|
},
|
|
|
|
// What to do when the weather provider has new information available?
|
|
updateAvailable () {
|
|
Log.log("[weather] New weather information available.");
|
|
this.updateDom(300);
|
|
|
|
const currentWeather = this.currentWeatherObject;
|
|
|
|
if (currentWeather) {
|
|
this.sendNotification("CURRENTWEATHER_TYPE", { type: currentWeather.weatherType?.replace("-", "_") });
|
|
}
|
|
|
|
const notificationPayload = {
|
|
currentWeather: this.config.units === "imperial"
|
|
? WeatherUtils.convertWeatherObjectToImperial(currentWeather?.simpleClone()) ?? null
|
|
: currentWeather?.simpleClone() ?? null,
|
|
forecastArray: this.config.units === "imperial"
|
|
? this.getForecastArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
|
|
: this.getForecastArray()?.map((ar) => ar.simpleClone()) ?? [],
|
|
hourlyArray: this.config.units === "imperial"
|
|
? this.getHourlyArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
|
|
: this.getHourlyArray()?.map((ar) => ar.simpleClone()) ?? [],
|
|
locationName: this.fetchedLocationName,
|
|
providerName: this.config.weatherProvider
|
|
};
|
|
|
|
this.sendNotification("WEATHER_UPDATED", notificationPayload);
|
|
},
|
|
|
|
getForecastArray () {
|
|
return this.weatherForecastArray;
|
|
},
|
|
|
|
getHourlyArray () {
|
|
return this.weatherHourlyArray;
|
|
},
|
|
|
|
// scheduleUpdate removed - all providers use server-driven fetching via HTTPFetcher
|
|
|
|
roundValue (temperature) {
|
|
if (temperature === null || temperature === undefined) {
|
|
return "";
|
|
}
|
|
const decimals = this.config.roundTemp ? 0 : 1;
|
|
const roundValue = parseFloat(temperature).toFixed(decimals);
|
|
if (roundValue === "NaN") {
|
|
return "";
|
|
}
|
|
return roundValue === "-0" ? 0 : roundValue;
|
|
},
|
|
|
|
addFilters () {
|
|
this.nunjucksEnvironment().addFilter(
|
|
"formatTime",
|
|
function (date) {
|
|
return formatTime(this.config, date);
|
|
}.bind(this)
|
|
);
|
|
|
|
this.nunjucksEnvironment().addFilter(
|
|
"unit",
|
|
function (value, type, valueUnit) {
|
|
let formattedValue;
|
|
if (type === "temperature") {
|
|
if (value === null || value === undefined) {
|
|
formattedValue = "";
|
|
} else {
|
|
formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`;
|
|
if (this.config.degreeLabel) {
|
|
if (this.config.tempUnits === "metric") {
|
|
formattedValue += "C";
|
|
} else if (this.config.tempUnits === "imperial") {
|
|
formattedValue += "F";
|
|
} else {
|
|
formattedValue += "K";
|
|
}
|
|
}
|
|
}
|
|
} else if (type === "precip") {
|
|
if (value === null || isNaN(value)) {
|
|
formattedValue = "";
|
|
} else {
|
|
formattedValue = WeatherUtils.convertPrecipitationUnit(value, valueUnit, this.config.units);
|
|
}
|
|
} else if (type === "humidity") {
|
|
formattedValue = `${value}%`;
|
|
} else if (type === "wind") {
|
|
formattedValue = WeatherUtils.convertWind(value, this.config.windUnits);
|
|
}
|
|
return formattedValue;
|
|
}.bind(this)
|
|
);
|
|
|
|
this.nunjucksEnvironment().addFilter(
|
|
"roundValue",
|
|
function (value) {
|
|
return this.roundValue(value);
|
|
}.bind(this)
|
|
);
|
|
|
|
this.nunjucksEnvironment().addFilter(
|
|
"decimalSymbol",
|
|
function (value) {
|
|
return value.toString().replace(/\./g, this.config.decimalSymbol);
|
|
}.bind(this)
|
|
);
|
|
|
|
this.nunjucksEnvironment().addFilter(
|
|
"calcNumSteps",
|
|
function (forecast) {
|
|
return Math.min(forecast.length, this.config.maxNumberOfDays);
|
|
}.bind(this)
|
|
);
|
|
|
|
this.nunjucksEnvironment().addFilter(
|
|
"calcNumEntries",
|
|
function (dataArray) {
|
|
return Math.min(dataArray.length, this.config.maxEntries);
|
|
}.bind(this)
|
|
);
|
|
|
|
this.nunjucksEnvironment().addFilter(
|
|
"opacity",
|
|
function (currentStep, numSteps) {
|
|
if (this.config.fade && this.config.fadePoint < 1) {
|
|
if (this.config.fadePoint < 0) {
|
|
this.config.fadePoint = 0;
|
|
}
|
|
const startingPoint = numSteps * this.config.fadePoint;
|
|
const numFadesteps = numSteps - startingPoint;
|
|
if (currentStep >= startingPoint) {
|
|
return 1 - (currentStep - startingPoint) / numFadesteps;
|
|
} else {
|
|
return 1;
|
|
}
|
|
} else {
|
|
return 1;
|
|
}
|
|
}.bind(this)
|
|
);
|
|
}
|
|
});
|