mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-04-23 14:27:01 +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" />
182 lines
4.8 KiB
JavaScript
182 lines
4.8 KiB
JavaScript
/**
|
|
* Shared utility functions for weather providers
|
|
*/
|
|
|
|
const SunCalc = require("suncalc");
|
|
|
|
/**
|
|
* Convert OpenWeatherMap icon codes to internal weather types
|
|
* @param {string} weatherType - OpenWeatherMap icon code (e.g., "01d", "02n")
|
|
* @returns {string|null} Internal weather type
|
|
*/
|
|
function convertWeatherType (weatherType) {
|
|
const weatherTypes = {
|
|
"01d": "day-sunny",
|
|
"02d": "day-cloudy",
|
|
"03d": "cloudy",
|
|
"04d": "cloudy-windy",
|
|
"09d": "showers",
|
|
"10d": "rain",
|
|
"11d": "thunderstorm",
|
|
"13d": "snow",
|
|
"50d": "fog",
|
|
"01n": "night-clear",
|
|
"02n": "night-cloudy",
|
|
"03n": "night-cloudy",
|
|
"04n": "night-cloudy",
|
|
"09n": "night-showers",
|
|
"10n": "night-rain",
|
|
"11n": "night-thunderstorm",
|
|
"13n": "night-snow",
|
|
"50n": "night-alt-cloudy-windy"
|
|
};
|
|
|
|
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
|
|
}
|
|
|
|
/**
|
|
* Apply timezone offset to a date
|
|
* @param {Date} date - The date to apply offset to
|
|
* @param {number} offsetMinutes - Timezone offset in minutes
|
|
* @returns {Date} Date with applied offset
|
|
*/
|
|
function applyTimezoneOffset (date, offsetMinutes) {
|
|
const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000);
|
|
return new Date(utcTime + (offsetMinutes * 60000));
|
|
}
|
|
|
|
/**
|
|
* Limit decimal places for coordinates (truncate, not round)
|
|
* @param {number} value - The coordinate value
|
|
* @param {number} decimals - Maximum number of decimal places
|
|
* @returns {number} Value with limited decimal places
|
|
*/
|
|
function limitDecimals (value, decimals) {
|
|
const str = value.toString();
|
|
if (str.includes(".")) {
|
|
const parts = str.split(".");
|
|
if (parts[1].length > decimals) {
|
|
return parseFloat(`${parts[0]}.${parts[1].substring(0, decimals)}`);
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Get sunrise and sunset times for a given date and location
|
|
* @param {Date} date - The date to calculate for
|
|
* @param {number} lat - Latitude
|
|
* @param {number} lon - Longitude
|
|
* @returns {object} Object with sunrise and sunset Date objects
|
|
*/
|
|
function getSunTimes (date, lat, lon) {
|
|
const sunTimes = SunCalc.getTimes(date, lat, lon);
|
|
return {
|
|
sunrise: sunTimes.sunrise,
|
|
sunset: sunTimes.sunset
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a given time is during daylight hours
|
|
* @param {Date} date - The date/time to check
|
|
* @param {Date} sunrise - Sunrise time
|
|
* @param {Date} sunset - Sunset time
|
|
* @returns {boolean} True if during daylight hours
|
|
*/
|
|
function isDayTime (date, sunrise, sunset) {
|
|
if (!sunrise || !sunset) {
|
|
return true; // Default to day if times unavailable
|
|
}
|
|
return date >= sunrise && date < sunset;
|
|
}
|
|
|
|
/**
|
|
* Format timezone offset as string (e.g., "+01:00", "-05:30")
|
|
* @param {number} offsetMinutes - Timezone offset in minutes (use -new Date().getTimezoneOffset() for local)
|
|
* @returns {string} Formatted offset string
|
|
*/
|
|
function formatTimezoneOffset (offsetMinutes) {
|
|
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
|
const minutes = Math.abs(offsetMinutes) % 60;
|
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
return `${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* Get date string in YYYY-MM-DD format (local time)
|
|
* @param {Date} date - The date to format
|
|
* @returns {string} Date string in YYYY-MM-DD format
|
|
*/
|
|
function getDateString (date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
/**
|
|
* Convert wind speed from km/h to m/s
|
|
* @param {number} kmh - Wind speed in km/h
|
|
* @returns {number} Wind speed in m/s
|
|
*/
|
|
function convertKmhToMs (kmh) {
|
|
return kmh / 3.6;
|
|
}
|
|
|
|
/**
|
|
* Convert cardinal wind direction string to degrees
|
|
* @param {string} direction - Cardinal direction (e.g., "N", "NNE", "SW")
|
|
* @returns {number|null} Direction in degrees (0-360) or null if unknown
|
|
*/
|
|
function cardinalToDegrees (direction) {
|
|
const directions = {
|
|
N: 0,
|
|
NNE: 22.5,
|
|
NE: 45,
|
|
ENE: 67.5,
|
|
E: 90,
|
|
ESE: 112.5,
|
|
SE: 135,
|
|
SSE: 157.5,
|
|
S: 180,
|
|
SSW: 202.5,
|
|
SW: 225,
|
|
WSW: 247.5,
|
|
W: 270,
|
|
WNW: 292.5,
|
|
NW: 315,
|
|
NNW: 337.5
|
|
};
|
|
return directions[direction] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Validate and limit coordinate precision
|
|
* @param {object} config - Configuration object with lat/lon properties
|
|
* @param {number} maxDecimals - Maximum decimal places to preserve
|
|
* @throws {Error} If coordinates are missing or invalid
|
|
*/
|
|
function validateCoordinates (config, maxDecimals = 4) {
|
|
if (config.lat == null || config.lon == null
|
|
|| !Number.isFinite(config.lat) || !Number.isFinite(config.lon)) {
|
|
throw new Error("Latitude and longitude are required");
|
|
}
|
|
|
|
config.lat = limitDecimals(config.lat, maxDecimals);
|
|
config.lon = limitDecimals(config.lon, maxDecimals);
|
|
}
|
|
|
|
module.exports = {
|
|
convertWeatherType,
|
|
applyTimezoneOffset,
|
|
limitDecimals,
|
|
getSunTimes,
|
|
isDayTime,
|
|
formatTimezoneOffset,
|
|
getDateString,
|
|
convertKmhToMs,
|
|
cardinalToDegrees,
|
|
validateCoordinates
|
|
};
|