mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-06-17 21:16:37 +00:00
8ce0cda7bf
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" />
468 lines
14 KiB
JavaScript
468 lines
14 KiB
JavaScript
const Log = require("logger");
|
|
const { formatTimezoneOffset, getDateString, validateCoordinates } = require("../provider-utils");
|
|
const HTTPFetcher = require("#http_fetcher");
|
|
|
|
/**
|
|
* Server-side weather provider for Yr.no (Norwegian Meteorological Institute)
|
|
* Terms of service: https://developer.yr.no/doc/TermsOfService/
|
|
*
|
|
* Note: Minimum update interval is 10 minutes (600000 ms) per API terms
|
|
*/
|
|
class YrProvider {
|
|
constructor (config) {
|
|
this.config = {
|
|
apiBase: "https://api.met.no/weatherapi",
|
|
forecastApiVersion: "2.0",
|
|
sunriseApiVersion: "3.0",
|
|
altitude: 0,
|
|
lat: 0,
|
|
lon: 0,
|
|
currentForecastHours: 1, // 1, 6 or 12
|
|
type: "current",
|
|
updateInterval: 10 * 60 * 1000, // 10 minutes minimum
|
|
...config
|
|
};
|
|
|
|
// Enforce 10 minute minimum per API terms
|
|
if (this.config.updateInterval < 600000) {
|
|
Log.warn("[yr] Minimum update interval is 10 minutes (600000 ms). Adjusting configuration.");
|
|
this.config.updateInterval = 600000;
|
|
}
|
|
|
|
this.fetcher = null;
|
|
this.onDataCallback = null;
|
|
this.onErrorCallback = null;
|
|
this.locationName = null;
|
|
|
|
// Cache for sunrise/sunset data
|
|
this.stellarData = null;
|
|
this.stellarDataDate = null;
|
|
|
|
// Cache for weather data (If-Modified-Since support)
|
|
this.weatherCache = {
|
|
data: null,
|
|
lastModified: null,
|
|
expires: null
|
|
};
|
|
}
|
|
|
|
async initialize () {
|
|
// Yr.no requires max 4 decimal places
|
|
validateCoordinates(this.config, 4);
|
|
await this.#fetchStellarData();
|
|
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();
|
|
}
|
|
}
|
|
|
|
async #fetchStellarData () {
|
|
const today = getDateString(new Date());
|
|
|
|
// Check if we already have today's data
|
|
if (this.stellarDataDate === today && this.stellarData) {
|
|
return;
|
|
}
|
|
|
|
const url = this.#getSunriseUrl();
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"User-Agent": "MagicMirror",
|
|
Accept: "application/json"
|
|
},
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
Log.warn(`[yr] Could not fetch stellar data: HTTP ${response.status}`);
|
|
this.stellarDataDate = today;
|
|
} else {
|
|
// Parse and store the stellar data
|
|
const data = await response.json();
|
|
// Transform single-day response into array format expected by #getStellarInfoForDate
|
|
if (data && data.properties) {
|
|
this.stellarData = [{
|
|
date: data.when.interval[0], // ISO date string
|
|
sunrise: data.properties.sunrise,
|
|
sunset: data.properties.sunset
|
|
}];
|
|
}
|
|
this.stellarDataDate = today;
|
|
}
|
|
} catch (error) {
|
|
Log.warn("[yr] Failed to fetch stellar data:", error);
|
|
}
|
|
}
|
|
|
|
#initializeFetcher () {
|
|
const url = this.#getForecastUrl();
|
|
|
|
const headers = {
|
|
"User-Agent": "MagicMirror",
|
|
Accept: "application/json"
|
|
};
|
|
|
|
// Add If-Modified-Since header if we have cached data
|
|
if (this.weatherCache.lastModified) {
|
|
headers["If-Modified-Since"] = this.weatherCache.lastModified;
|
|
}
|
|
|
|
this.fetcher = new HTTPFetcher(url, {
|
|
reloadInterval: this.config.updateInterval,
|
|
headers,
|
|
logContext: "weatherprovider.yr"
|
|
});
|
|
|
|
this.fetcher.on("response", async (response) => {
|
|
try {
|
|
// Handle 304 Not Modified - use cached data
|
|
if (response.status === 304) {
|
|
Log.log("[yr] Data not modified, using cache");
|
|
if (this.weatherCache.data) {
|
|
this.#handleResponse(this.weatherCache.data, true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Store cache headers
|
|
const lastModified = response.headers.get("Last-Modified");
|
|
const expires = response.headers.get("Expires");
|
|
|
|
if (lastModified) {
|
|
this.weatherCache.lastModified = lastModified;
|
|
}
|
|
if (expires) {
|
|
this.weatherCache.expires = expires;
|
|
}
|
|
this.weatherCache.data = data;
|
|
|
|
// Update headers for next request
|
|
if (lastModified && this.fetcher) {
|
|
this.fetcher.customHeaders["If-Modified-Since"] = lastModified;
|
|
}
|
|
|
|
this.#handleResponse(data, false);
|
|
} catch (error) {
|
|
Log.error("[yr] 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
async #handleResponse (data, fromCache = false) {
|
|
try {
|
|
if (!data.properties || !data.properties.timeseries) {
|
|
throw new Error("Invalid weather data");
|
|
}
|
|
|
|
// Refresh stellar data if needed (new day or using cached weather data)
|
|
if (fromCache) {
|
|
await this.#fetchStellarData();
|
|
}
|
|
|
|
let weatherData;
|
|
|
|
switch (this.config.type) {
|
|
case "current":
|
|
weatherData = this.#generateCurrentWeather(data);
|
|
break;
|
|
case "forecast":
|
|
case "daily":
|
|
weatherData = this.#generateForecast(data);
|
|
break;
|
|
case "hourly":
|
|
weatherData = this.#generateHourly(data);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown weather type: ${this.config.type}`);
|
|
}
|
|
|
|
if (this.onDataCallback) {
|
|
this.onDataCallback(weatherData);
|
|
}
|
|
} catch (error) {
|
|
Log.error("[yr] Error processing weather data:", error);
|
|
if (this.onErrorCallback) {
|
|
this.onErrorCallback({
|
|
message: error.message,
|
|
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#generateCurrentWeather (data) {
|
|
const now = new Date();
|
|
const timeseries = data.properties.timeseries;
|
|
|
|
// Find closest forecast in the past
|
|
let forecast = timeseries[0];
|
|
let closestDiff = Math.abs(now - new Date(forecast.time));
|
|
|
|
for (const entry of timeseries) {
|
|
const entryTime = new Date(entry.time);
|
|
const diff = now - entryTime;
|
|
|
|
if (diff > 0 && diff < closestDiff) {
|
|
closestDiff = diff;
|
|
forecast = entry;
|
|
}
|
|
}
|
|
|
|
const forecastXHours = this.#getForecastForXHours(forecast.data);
|
|
const stellarInfo = this.#getStellarInfoForDate(new Date(forecast.time));
|
|
|
|
const current = {};
|
|
current.date = new Date(forecast.time);
|
|
current.temperature = forecast.data.instant.details.air_temperature;
|
|
current.windSpeed = forecast.data.instant.details.wind_speed;
|
|
current.windFromDirection = forecast.data.instant.details.wind_from_direction;
|
|
current.humidity = forecast.data.instant.details.relative_humidity;
|
|
current.weatherType = this.#convertWeatherType(
|
|
forecastXHours.summary?.symbol_code,
|
|
stellarInfo ? this.#isDayTime(current.date, stellarInfo) : true
|
|
);
|
|
current.precipitationAmount = forecastXHours.details?.precipitation_amount;
|
|
current.precipitationProbability = forecastXHours.details?.probability_of_precipitation;
|
|
current.minTemperature = forecastXHours.details?.air_temperature_min;
|
|
current.maxTemperature = forecastXHours.details?.air_temperature_max;
|
|
|
|
if (stellarInfo) {
|
|
current.sunrise = new Date(stellarInfo.sunrise.time);
|
|
current.sunset = new Date(stellarInfo.sunset.time);
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
#generateForecast (data) {
|
|
const timeseries = data.properties.timeseries;
|
|
const dailyData = new Map();
|
|
|
|
// Collect all data points for each day
|
|
for (const entry of timeseries) {
|
|
const date = new Date(entry.time);
|
|
const dateStr = getDateString(date);
|
|
|
|
if (!dailyData.has(dateStr)) {
|
|
dailyData.set(dateStr, {
|
|
date: date,
|
|
temps: [],
|
|
precip: [],
|
|
precipProb: [],
|
|
symbols: []
|
|
});
|
|
}
|
|
|
|
const dayData = dailyData.get(dateStr);
|
|
|
|
// Collect temperature from instant data
|
|
if (entry.data.instant?.details?.air_temperature !== undefined) {
|
|
dayData.temps.push(entry.data.instant.details.air_temperature);
|
|
}
|
|
|
|
// Collect data from forecast periods (prefer longer periods to avoid double-counting)
|
|
const forecast = entry.data.next_12_hours || entry.data.next_6_hours || entry.data.next_1_hours;
|
|
if (forecast) {
|
|
if (forecast.details?.precipitation_amount !== undefined) {
|
|
dayData.precip.push(forecast.details.precipitation_amount);
|
|
}
|
|
if (forecast.details?.probability_of_precipitation !== undefined) {
|
|
dayData.precipProb.push(forecast.details.probability_of_precipitation);
|
|
}
|
|
if (forecast.summary?.symbol_code) {
|
|
dayData.symbols.push(forecast.summary.symbol_code);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert collected data to forecast objects
|
|
const days = [];
|
|
for (const [dateStr, data] of dailyData) {
|
|
const stellarInfo = this.#getStellarInfoForDate(data.date);
|
|
|
|
const dayData = {
|
|
date: data.date,
|
|
minTemperature: data.temps.length > 0 ? Math.min(...data.temps) : null,
|
|
maxTemperature: data.temps.length > 0 ? Math.max(...data.temps) : null,
|
|
precipitationAmount: data.precip.length > 0 ? Math.max(...data.precip) : null,
|
|
precipitationProbability: data.precipProb.length > 0 ? Math.max(...data.precipProb) : null,
|
|
weatherType: data.symbols.length > 0 ? this.#convertWeatherType(data.symbols[0], true) : null
|
|
};
|
|
|
|
if (stellarInfo) {
|
|
dayData.sunrise = new Date(stellarInfo.sunrise.time);
|
|
dayData.sunset = new Date(stellarInfo.sunset.time);
|
|
}
|
|
|
|
days.push(dayData);
|
|
}
|
|
|
|
// Sort by date to ensure correct order
|
|
return days.sort((a, b) => a.date - b.date);
|
|
}
|
|
|
|
#generateHourly (data) {
|
|
const hours = [];
|
|
const timeseries = data.properties.timeseries;
|
|
|
|
for (const entry of timeseries) {
|
|
const forecast1h = entry.data.next_1_hours;
|
|
if (!forecast1h) continue;
|
|
|
|
const date = new Date(entry.time);
|
|
const stellarInfo = this.#getStellarInfoForDate(date);
|
|
|
|
const hourly = {
|
|
date: date,
|
|
temperature: entry.data.instant.details.air_temperature,
|
|
windSpeed: entry.data.instant.details.wind_speed,
|
|
windFromDirection: entry.data.instant.details.wind_from_direction,
|
|
humidity: entry.data.instant.details.relative_humidity,
|
|
precipitationAmount: forecast1h.details?.precipitation_amount,
|
|
precipitationProbability: forecast1h.details?.probability_of_precipitation,
|
|
weatherType: this.#convertWeatherType(
|
|
forecast1h.summary?.symbol_code,
|
|
stellarInfo ? this.#isDayTime(date, stellarInfo) : true
|
|
)
|
|
};
|
|
|
|
hours.push(hourly);
|
|
}
|
|
|
|
return hours;
|
|
}
|
|
|
|
#getForecastForXHours (data) {
|
|
const hours = this.config.currentForecastHours;
|
|
|
|
if (hours === 12 && data.next_12_hours) {
|
|
return data.next_12_hours;
|
|
} else if (hours === 6 && data.next_6_hours) {
|
|
return data.next_6_hours;
|
|
} else if (data.next_1_hours) {
|
|
return data.next_1_hours;
|
|
}
|
|
|
|
return data.next_6_hours || data.next_12_hours || data.next_1_hours || {};
|
|
}
|
|
|
|
#getStellarInfoForDate (date) {
|
|
if (!this.stellarData) return null;
|
|
|
|
const dateStr = getDateString(date);
|
|
|
|
for (const day of this.stellarData) {
|
|
const dayDate = day.date.split("T")[0];
|
|
if (dayDate === dateStr) {
|
|
return day;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
#isDayTime (date, stellarInfo) {
|
|
if (!stellarInfo || !stellarInfo.sunrise || !stellarInfo.sunset) {
|
|
return true;
|
|
}
|
|
|
|
const sunrise = new Date(stellarInfo.sunrise.time);
|
|
const sunset = new Date(stellarInfo.sunset.time);
|
|
|
|
return date >= sunrise && date < sunset;
|
|
}
|
|
|
|
#convertWeatherType (symbolCode, isDayTime) {
|
|
if (!symbolCode) return null;
|
|
|
|
// Yr.no uses symbol codes like "clearsky_day", "partlycloudy_night", etc.
|
|
const symbol = symbolCode.replace(/_day|_night/g, "");
|
|
|
|
const mappings = {
|
|
clearsky: isDayTime ? "day-sunny" : "night-clear",
|
|
fair: isDayTime ? "day-sunny" : "night-clear",
|
|
partlycloudy: isDayTime ? "day-cloudy" : "night-cloudy",
|
|
cloudy: "cloudy",
|
|
fog: "fog",
|
|
lightrainshowers: isDayTime ? "day-showers" : "night-showers",
|
|
rainshowers: isDayTime ? "showers" : "night-showers",
|
|
heavyrainshowers: isDayTime ? "day-rain" : "night-rain",
|
|
lightrain: isDayTime ? "day-sprinkle" : "night-sprinkle",
|
|
rain: isDayTime ? "rain" : "night-rain",
|
|
heavyrain: isDayTime ? "rain" : "night-rain",
|
|
lightsleetshowers: isDayTime ? "day-sleet" : "night-sleet",
|
|
sleetshowers: isDayTime ? "sleet" : "night-sleet",
|
|
heavysleetshowers: isDayTime ? "sleet" : "night-sleet",
|
|
lightsleet: isDayTime ? "day-sleet" : "night-sleet",
|
|
sleet: "sleet",
|
|
heavysleet: "sleet",
|
|
lightsnowshowers: isDayTime ? "day-snow" : "night-snow",
|
|
snowshowers: isDayTime ? "snow" : "night-snow",
|
|
heavysnowshowers: isDayTime ? "snow" : "night-snow",
|
|
lightsnow: isDayTime ? "day-snow" : "night-snow",
|
|
snow: "snow",
|
|
heavysnow: "snow",
|
|
lightrainandthunder: isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
|
rainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm",
|
|
heavyrainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm",
|
|
lightsleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
|
|
sleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
|
|
heavysleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
|
|
lightsnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
|
snowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
|
heavysnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm"
|
|
};
|
|
|
|
return mappings[symbol] || null;
|
|
}
|
|
|
|
#getForecastUrl () {
|
|
const { lat, lon, altitude } = this.config;
|
|
return `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?altitude=${altitude}&lat=${lat}&lon=${lon}`;
|
|
}
|
|
|
|
#getSunriseUrl () {
|
|
const { lat, lon } = this.config;
|
|
const today = getDateString(new Date());
|
|
const offset = formatTimezoneOffset(-new Date().getTimezoneOffset());
|
|
return `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${today}&offset=${offset}`;
|
|
}
|
|
}
|
|
|
|
module.exports = YrProvider;
|