Release 2.36.0 (#4127)

## Release Notes
Thanks to: @cgillinger, @khassel, @KristjanESPERANTO, @sonnyb9
> ⚠️ This release needs nodejs version >=22.21.1 <23 || >=24 (no change
to previous release)

[Compare to previous Release
v2.35.0](https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.35.0...v2.36.0)

This release falls outside the quarterly schedule. We opted for an early
release due to:
- Security fix for the internal cors proxy
- API change of the weather provider smi
- Several bug fixes

### Breaking Changes

The cors proxy is now disabled by default. If required, it must be
explicitly enabled in the `config.js` file. See the
[documentation](https://docs.magicmirror.builders/configuration/cors.html).

### ⚠️ Security

You can find several publicly accessible MagicMirror² instances.

This should never be done. Doing so makes your entire configuration,
including secrets and API keys, publicly visible. Furthermore, it allows
attackers to target the host; this is only prevented beginning with this
release.

Public MagicMirror² instances should always run behind a reverse proxy
with authentication.

### [core]
- Prepare Release 2.36.0 (#4126)
- Allow HTTPFetcher to pass through 304 responses (#4120)
- fix(http-fetcher): fall back to reloadInterval after retries exhausted
(#4113)
- config endpoint must handle functions in module configs (#4106)
- fix replaceSecretPlaceholder (#4104)
- restrict replaceSecretPlaceholder to cors with allowWhitelist (#4102)
- fix: prevent crash when config is undefined in socket handler (#4096)
- fix cors function for alpine linux (#4091)
- fix(cors): prevent SSRF via DNS rebinding (#4090)
- add option to disable or restrict cors endpoint (#4087)
- fix: prevent SSRF via /cors endpoint by blocking private/reserved IPs
(#4084)
- chore: add permissions section to enforce pull-request rules workflow
(#4079)
- update version for develop

### [dependencies]
- update dependencies (#4124)
- chore: update dependencies (#4088)
- refactor: enable ESLint rule "no-unused-vars" and handle related
issues (#4080)

### [modules/newsfeed]
- fix(newsfeed): prevent duplicate parse error callback when using
pipeline (#4083)

### [modules/updatenotification]
- fix(updatenotification): harden git command execution + simplify
checkUpdates (#4115)
- fix(tests): correct import path for git_helper module in
updatenotification tests (#4078)

### [modules/weather]
- fix(weather): use nearest openmeteo hourly data (#4123)
- fix(weather): avoid loading state after reconnect (#4121)
- weather: fix UV index display and add WeatherFlow precipitation
(#4108)
- fix(weather): restore OpenWeatherMap v2.5 support (#4101)
- fix(weather): use stable instanceId to prevent duplicate fetchers
(#4092)
- SMHI: migrate to SNOW1gv1 API (replace deprecated PMP3gv2) (#4082)

### [testing]
- ci(actions): set explicit token permissions (#4114)
- fix(http_fetcher): use undici.fetch when dispatcher is present (#4097)
- ci(codeql): also scan develop branch on push and PR (#4086)
- refactor: replace implicit global config with explicit global.config
(#4085)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: sam detweiler <sdetweil@gmail.com>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: veeck <gitkraken@veeck.de>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com>
Co-authored-by: Nathan <n8nyoung@gmail.com>
Co-authored-by: mixasgr <mixasgr@users.noreply.github.com>
Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr>
Co-authored-by: Konstantinos <geraki@gmail.com>
Co-authored-by: OWL4C <124401812+OWL4C@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: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: Koen Konst <koenspero@gmail.com>
Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
Co-authored-by: dathbe <github@beffa.us>
Co-authored-by: Marcel <m-idler@users.noreply.github.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>
This commit is contained in:
Karsten Hassel
2026-04-30 22:49:25 +02:00
committed by GitHub
parent d05ea751d9
commit fb41d24ef5
61 changed files with 4551 additions and 3132 deletions

View File

@@ -167,7 +167,7 @@ Module.register("calendar", {
this.selfUpdate();
},
notificationReceived (notification, payload, sender) {
notificationReceived (notification, payload) {
if (notification === "FETCH_CALENDAR") {
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
}

View File

@@ -1,6 +1,5 @@
const ical = require("node-ical");
const Log = require("logger");
const { Agent } = require("undici");
const CalendarFetcherUtils = require("./calendarfetcherutils");
const HTTPFetcher = require("#http_fetcher");

View File

@@ -62,7 +62,7 @@ const CalendarFetcherUtils = {
// Subtract 1 second so that events that start on the middle of the night will not repeat.
.subtract(1, "seconds");
Object.entries(data).forEach(([key, event]) => {
Object.values(data).forEach((event) => {
if (event.type !== "VEVENT") {
return;
}

View File

@@ -308,7 +308,7 @@ Module.register("compliments", {
},
// Override notification handler.
notificationReceived (notification, payload, sender) {
notificationReceived (notification, payload) {
if (notification === "CURRENTWEATHER_TYPE") {
this.currentWeatherType = payload.type;
}

View File

@@ -411,7 +411,7 @@ Module.register("newsfeed", {
}
},
notificationReceived (notification, payload, sender) {
notificationReceived (notification) {
const before = this.activeItem;
if (notification === "MODULE_DOM_CREATED" && this.config.hideLoading) {
this.hide();

View File

@@ -40,7 +40,7 @@ class NewsfeedFetcher {
});
// Wire up HTTPFetcher events
this.httpFetcher.on("response", (response) => this.#handleResponse(response));
this.httpFetcher.on("response", (response) => void this.#handleResponse(response));
this.httpFetcher.on("error", (errorInfo) => this.fetchFailedCallback(this, errorInfo));
}
@@ -67,7 +67,7 @@ class NewsfeedFetcher {
* Handles successful HTTP response
* @param {Response} response - The fetch Response object
*/
#handleResponse (response) {
async #handleResponse (response) {
this.items = [];
const parser = new FeedMe();
@@ -106,11 +106,6 @@ class NewsfeedFetcher {
parser.on("end", () => this.broadcastItems());
parser.on("error", (error) => {
Log.error(`${this.url} - Feed parsing failed: ${error.message}`);
this.fetchFailedCallback(this, this.#createParseError(`Feed parsing failed: ${error.message}`, error));
});
parser.on("ttl", (minutes) => {
const ttlms = Math.min(minutes * 60 * 1000, 86400000);
if (ttlms > this.httpFetcher.reloadInterval) {
@@ -123,7 +118,7 @@ class NewsfeedFetcher {
const nodeStream = response.body instanceof stream.Readable
? response.body
: stream.Readable.fromWeb(response.body);
nodeStream.pipe(iconv.decodeStream(this.encoding)).pipe(parser);
await stream.promises.pipeline(nodeStream, iconv.decodeStream(this.encoding), parser);
} catch (error) {
Log.error(`${this.url} - Stream processing failed: ${error.message}`);
this.fetchFailedCallback(this, this.#createParseError(`Stream processing failed: ${error.message}`, error));

View File

@@ -1,5 +1,5 @@
const util = require("node:util");
const exec = util.promisify(require("node:child_process").exec);
const execFile = util.promisify(require("node:child_process").execFile);
const fs = require("node:fs");
const path = require("node:path");
const Log = require("logger");
@@ -14,14 +14,14 @@ class GitHelper {
return new RegExp(`s*([a-z,0-9]+[.][.][a-z,0-9]+) ${branch}`, "g");
}
async execShell (command) {
const { stdout = "", stderr = "" } = await exec(command);
async execGit (moduleFolder, ...args) {
const { stdout = "", stderr = "" } = await execFile("git", args, { cwd: moduleFolder });
return { stdout, stderr };
}
async isGitRepo (moduleFolder) {
const { stderr } = await this.execShell(`cd ${moduleFolder} && git remote -v`);
const { stderr } = await this.execGit(moduleFolder, "remote", "-v");
if (stderr) {
Log.error(`Failed to fetch git data for ${moduleFolder}: ${stderr}`);
@@ -51,7 +51,7 @@ class GitHelper {
// Folder has .git and has at least one git remote, watch this folder
this.gitRepos.push({ module: moduleName, folder: moduleFolder });
}
} catch (err) {
} catch {
// Error when directory .git doesn't exist or doesn't have any remotes
// This module is not managed with git, skip
}
@@ -69,7 +69,7 @@ class GitHelper {
if (repo.module === "MagicMirror") {
// the hash is only needed for the mm repo
const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git rev-parse HEAD`);
const { stderr, stdout } = await this.execGit(repo.folder, "rev-parse", "HEAD");
if (stderr) {
Log.error(`Failed to get current commit hash for ${repo.module}: ${stderr}`);
@@ -78,7 +78,7 @@ class GitHelper {
gitInfo.hash = stdout;
}
const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git status -sb`);
const { stderr, stdout } = await this.execGit(repo.folder, "status", "-sb");
if (stderr) {
Log.error(`Failed to get git status for ${repo.module}: ${stderr}`);
@@ -123,7 +123,7 @@ class GitHelper {
return gitInfo;
}
const { stderr } = await this.execShell(`cd ${repo.folder} && git fetch -n --dry-run`);
const { stderr } = await this.execGit(repo.folder, "fetch", "-n", "--dry-run");
// example output:
// From https://github.com/MagicMirrorOrg/MagicMirror
@@ -140,7 +140,7 @@ class GitHelper {
// get behind with refs
try {
const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path --count ${refDiff}`);
const { stdout } = await this.execGit(repo.folder, "rev-list", "--ancestry-path", "--count", refDiff);
gitInfo.behind = parseInt(stdout);
// for MagicMirror-Repo and "master" branch avoid getting notified when no tag is in refDiff
@@ -148,14 +148,14 @@ class GitHelper {
if (gitInfo.behind > 0 && gitInfo.module === "MagicMirror" && gitInfo.current === "master") {
let tagList = "";
try {
const { stdout } = await this.execShell(`cd ${repo.folder} && git ls-remote -q --tags --refs`);
const { stdout } = await this.execGit(repo.folder, "ls-remote", "-q", "--tags", "--refs");
tagList = stdout.trim();
} catch (err) {
Log.error(`Failed to get tag list for ${repo.module}: ${err}`);
}
// check if tag is between commits and only report behind > 0 if so
try {
const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path ${refDiff}`);
const { stdout } = await this.execGit(repo.folder, "rev-list", "--ancestry-path", refDiff);
let cnt = 0;
for (const ref of stdout.trim().split("\n")) {
if (tagList.includes(ref)) cnt++; // tag found
@@ -193,19 +193,15 @@ class GitHelper {
return this.gitResultList;
}
async checkUpdates () {
var updates = [];
checkUpdates () {
const updates = [];
const allRepos = await this.gitResultList.map((module) => {
return new Promise((resolve) => {
if (module.behind > 0 && module.module !== "MagicMirror") {
Log.info(`Update found for module: ${module.module}`);
updates.push(module);
}
resolve(module);
});
});
await Promise.all(allRepos);
for (const moduleInfo of this.gitResultList) {
if (moduleInfo.behind > 0 && moduleInfo.module !== "MagicMirror") {
Log.info(`Update found for module: ${moduleInfo.module}`);
updates.push(moduleInfo);
}
}
return updates;
}

View File

@@ -113,7 +113,7 @@ class Updater {
Log.info(`Updating ${module.name}...`);
return new Promise((resolve) => {
Exec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout, stderr) => {
Exec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout) => {
if (error) {
Log.error(`exec error: ${error}`);
Result.error = true;
@@ -143,7 +143,7 @@ class Updater {
pm2Restart () {
Log.info("[PM2] restarting MagicMirror...");
const pm2 = require("pm2");
pm2.restart(this.PM2Id, (err, proc) => {
pm2.restart(this.PM2Id, (err) => {
if (err) {
Log.error("[PM2] restart Error", err);
}

View File

@@ -38,7 +38,7 @@
{% if config.showUVIndex %}
<td class="align-right bright uv-index">
<div class="wi dimmed wi-hot"></div>
{{ current.uv_index }}
{{ current.uvIndex }}
</td>
{% endif %}
</div>
@@ -78,11 +78,11 @@
</span>
<br />
{% endif %}
{% if config.showPrecipitationAmount and current.precipitationAmount %}
{% if config.showPrecipitationAmount and current.precipitationAmount is defined and current.precipitationAmount is not none %}
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_AMOUNT" | translate }}</span> {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }} </span>
<br />
{% endif %}
{% if config.showPrecipitationProbability and current.precipitationProbability %}
{% if config.showPrecipitationProbability and current.precipitationProbability is defined and current.precipitationProbability is not none %}
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}% </span>
{% endif %}
</div>

View File

@@ -31,7 +31,7 @@
{% endif %}
{% if config.showUVIndex %}
<td class="align-right dimmed uv-index">
{{ f.uv_index }}
{{ f.uvIndex }}
<span class="wi dimmed weathericon wi-hot"></span>
</td>
{% endif %}

View File

@@ -15,8 +15,8 @@
<td class="align-right bright">{{ hour.temperature | roundValue | unit("temperature") }}</td>
{% if config.showUVIndex %}
<td class="align-right bright uv-index">
{% if hour.uv_index!=0 %}
{{ hour.uv_index }}
{% if hour.uvIndex!=0 %}
{{ hour.uvIndex }}
<span class="wi weathericon wi-hot"></span>
{% endif %}
</td>

View File

@@ -4,6 +4,7 @@ const Log = require("logger");
module.exports = NodeHelper.create({
providers: {},
lastData: {},
start () {
Log.log(`Starting node helper for: ${this.name}`);
@@ -31,7 +32,16 @@ module.exports = NodeHelper.create({
Log.log(`Attempting to initialize provider ${identifier} for instance ${instanceId}`);
if (this.providers[instanceId]) {
Log.log(`Weather provider ${identifier} already initialized for instance ${instanceId}`);
Log.log(`Weather provider ${identifier} already initialized for instance ${instanceId}, re-sending WEATHER_INITIALIZED`);
// Client may have restarted (e.g. page reload) - re-send so it recovers location name
this.sendSocketNotification("WEATHER_INITIALIZED", {
instanceId,
locationName: this.providers[instanceId].locationName
});
// Push cached data immediately so reconnecting clients don't wait for next scheduled fetch
if (this.lastData[instanceId]) {
this.sendSocketNotification("WEATHER_DATA", this.lastData[instanceId]);
}
return;
}
@@ -48,11 +58,9 @@ module.exports = NodeHelper.create({
provider.setCallbacks(
(data) => {
// On data received
this.sendSocketNotification("WEATHER_DATA", {
instanceId,
type: config.type,
data
});
const payload = { instanceId, type: config.type, data };
this.lastData[instanceId] = payload;
this.sendSocketNotification("WEATHER_DATA", payload);
},
(errorInfo) => {
// On error
@@ -96,6 +104,7 @@ module.exports = NodeHelper.create({
Log.log(`Stopping weather provider for instance ${instanceId}`);
provider.stop();
delete this.providers[instanceId];
delete this.lastData[instanceId];
} else {
Log.warn(`No provider found for instance ${instanceId}`);
}

View File

@@ -418,46 +418,18 @@ class OpenMeteoProvider {
// Add hourly data if available
if (parsedData.hourly) {
let h;
const currentTime = parsedData.current_weather.time;
// Handle both data shapes: object with arrays or array of objects (after transpose)
if (Array.isArray(parsedData.hourly)) {
// Array of objects (after transpose)
const hourlyIndex = parsedData.hourly.findIndex((hour) => hour.time.getTime() === currentTime.getTime());
h = hourlyIndex !== -1 ? hourlyIndex : 0;
if (hourlyIndex === -1) {
Log.debug("[openmeteo] Could not find current time in hourly data, using index 0");
}
const hourData = parsedData.hourly[h];
if (hourData) {
current.humidity = hourData.relativehumidity_2m;
current.feelsLikeTemp = hourData.apparent_temperature;
current.rain = hourData.rain;
current.snow = hourData.snowfall ? hourData.snowfall * 10 : undefined;
current.precipitationAmount = hourData.precipitation;
current.precipitationProbability = hourData.precipitation_probability;
current.uvIndex = hourData.uv_index;
}
} else if (parsedData.hourly.time) {
// Object with arrays (before transpose - shouldn't happen in normal flow)
const hourlyIndex = parsedData.hourly.time.findIndex((time) => time === currentTime);
h = hourlyIndex !== -1 ? hourlyIndex : 0;
if (hourlyIndex === -1) {
Log.debug("[openmeteo] Could not find current time in hourly data, using index 0");
}
current.humidity = parsedData.hourly.relativehumidity_2m?.[h];
current.feelsLikeTemp = parsedData.hourly.apparent_temperature?.[h];
current.rain = parsedData.hourly.rain?.[h];
current.snow = parsedData.hourly.snowfall?.[h] ? parsedData.hourly.snowfall[h] * 10 : undefined;
current.precipitationAmount = parsedData.hourly.precipitation?.[h];
current.precipitationProbability = parsedData.hourly.precipitation_probability?.[h];
current.uvIndex = parsedData.hourly.uv_index?.[h];
}
// Open-Meteo updates current_weather every 15 min, but hourly entries only
// exist at full hours — find the last entry at or before the current time.
const currentMs = parsedData.current_weather.time.getTime();
const hourlyIndex = parsedData.hourly.findLastIndex((hour) => hour.time.getTime() <= currentMs);
const hourData = parsedData.hourly[Math.max(0, hourlyIndex)];
current.humidity = hourData.relativehumidity_2m;
current.feelsLikeTemp = hourData.apparent_temperature;
current.rain = hourData.rain;
current.snow = hourData.snowfall != null ? hourData.snowfall * 10 : null;
current.precipitationAmount = hourData.precipitation;
current.precipitationProbability = hourData.precipitation_probability;
current.uvIndex = hourData.uv_index;
}
// Add daily data if available (after transpose, daily is array of objects)

View File

@@ -96,28 +96,41 @@ class OpenWeatherMapProvider {
#handleResponse (data) {
try {
// Set location name from timezone
if (data.timezone) {
this.locationName = data.timezone;
}
let weatherData;
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}`);
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) {
@@ -134,6 +147,123 @@ class OpenWeatherMapProvider {
}
}
#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;

View File

@@ -5,8 +5,32 @@ const HTTPFetcher = require("#http_fetcher");
/**
* Server-side weather provider for SMHI (Swedish Meteorological and Hydrological Institute)
* Sweden only, metric system
* API: https://opendata.smhi.se/apidocs/metfcst/
*
* API: SNOW1gv1 — https://opendata.smhi.se/metfcst/snow1gv1
* Migrated from PMP3gv2 (deprecated 2026-03-31, returns HTTP 404)
*
* Version: 2.0.1 (2026-04-02)
*
* Key differences from PMP3gv2:
* - URL: snow1g/version/1 (was pmp3g/version/2)
* - Time key: "time" (was "validTime")
* - Data structure: flat object entry.data.X (was parameters[].find().values[0])
* - Parameter names: human-readable (air_temperature, wind_speed, etc.)
* - Coordinates: flat [lon, lat] (was nested [[lon, lat]])
* - Precipitation types: different value mapping (1=rain, not snow)
*/
/**
* Maps user-facing config precipitationValue to SNOW1gv1 parameter names.
* Maintains backward compatibility with existing MagicMirror configs.
*/
const PRECIP_VALUE_MAP = {
pmin: "precipitation_amount_min",
pmean: "precipitation_amount_mean",
pmedian: "precipitation_amount_median",
pmax: "precipitation_amount_max"
};
class SMHIProvider {
constructor (config) {
this.config = {
@@ -19,7 +43,7 @@ class SMHIProvider {
};
// Validate precipitationValue
if (!["pmin", "pmean", "pmedian", "pmax"].includes(this.config.precipitationValue)) {
if (!Object.keys(PRECIP_VALUE_MAP).includes(this.config.precipitationValue)) {
Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`);
this.config.precipitationValue = "pmedian";
}
@@ -152,14 +176,20 @@ class SMHIProvider {
return this.#convertWeatherDataGroupedBy(filled, coordinates, "hour");
}
/**
* Find the time series entry closest to the current time.
* SNOW1gv1 uses "time" instead of PMP3gv2's "validTime".
* @param {Array<object>} times - Array of SNOW1gv1 time series entries.
* @returns {object} The time series entry closest to the current time.
*/
#getClosestToCurrentTime (times) {
const now = new Date();
let minDiff = null;
let closest = times[0];
for (const time of times) {
const validTime = new Date(time.validTime);
const diff = Math.abs(validTime - now);
const entryTime = new Date(time.time);
const diff = Math.abs(entryTime - now);
if (minDiff === null || diff < minDiff) {
minDiff = diff;
@@ -170,18 +200,27 @@ class SMHIProvider {
return closest;
}
/**
* Convert a single SNOW1gv1 time series entry to MagicMirror weather object.
*
* SNOW1gv1 data structure: entry.data.parameter_name (flat object)
* PMP3gv2 used: entry.parameters[{name, values}] (array of objects)
* @param {object} weatherData - A single SNOW1gv1 time series entry.
* @param {object} coordinates - Object with lat and lon properties.
* @returns {object} MagicMirror-formatted weather data object.
*/
#convertWeatherDataToObject (weatherData, coordinates) {
const date = new Date(weatherData.validTime);
const date = new Date(weatherData.time);
const { sunrise, sunset } = getSunTimes(date, coordinates.lat, coordinates.lon);
const isDay = isDayTime(date, sunrise, sunset);
const current = {
date: date,
humidity: this.#paramValue(weatherData, "r"),
temperature: this.#paramValue(weatherData, "t"),
windSpeed: this.#paramValue(weatherData, "ws"),
windFromDirection: this.#paramValue(weatherData, "wd"),
weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "Wsymb2"), isDay),
humidity: this.#paramValue(weatherData, "relative_humidity"),
temperature: this.#paramValue(weatherData, "air_temperature"),
windSpeed: this.#paramValue(weatherData, "wind_speed"),
windFromDirection: this.#paramValue(weatherData, "wind_from_direction"),
weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "symbol_code"), isDay),
feelsLikeTemp: this.#calculateApparentTemperature(weatherData),
sunrise: sunrise,
sunset: sunset,
@@ -190,28 +229,37 @@ class SMHIProvider {
precipitationAmount: 0
};
// Determine precipitation amount and category
const precipitationValue = this.#paramValue(weatherData, this.config.precipitationValue);
const pcat = this.#paramValue(weatherData, "pcat");
// Map user config (pmedian/pmean/pmin/pmax) to SNOW1gv1 parameter name
const precipParamName = PRECIP_VALUE_MAP[this.config.precipitationValue];
const precipitationValue = this.#paramValue(weatherData, precipParamName);
const pcat = this.#paramValue(weatherData, "predominant_precipitation_type_at_surface");
// SNOW1gv1 precipitation type mapping (differs from PMP3gv2!):
// 0 = no precipitation
// 1 = rain
// 2 = sleet (snow + rain mix)
// 5 = snow / freezing rain
// 6 = freezing mixed precipitation
// 11 = drizzle / light rain
switch (pcat) {
case 1: // Snow
current.snow = precipitationValue;
case 1: // Rain
case 11: // Drizzle / light rain
current.rain = precipitationValue;
current.precipitationAmount = precipitationValue;
break;
case 2: // Snow and rain (50/50 split)
case 2: // Sleet / mixed rain and snow
current.snow = precipitationValue / 2;
current.rain = precipitationValue / 2;
current.precipitationAmount = precipitationValue;
break;
case 3: // Rain
case 4: // Drizzle
case 5: // Freezing rain
case 6: // Freezing drizzle
current.rain = precipitationValue;
case 5: // Snow / freezing rain
case 6: // Freezing mixed precipitation
current.snow = precipitationValue;
current.precipitationAmount = precipitationValue;
break;
// case 0: No precipitation - defaults already set to 0
case 0:
default:
break;
}
return current;
@@ -285,15 +333,22 @@ class SMHIProvider {
}
}
/**
* Fill gaps in time series data for forecast/hourly grouping.
* SNOW1gv1 has variable time steps: 1h (0-48h), 2h (49-72h), 6h (73-132h), 12h (133h+).
* Uses "time" key instead of PMP3gv2's "validTime".
* @param {Array<object>} data - Array of SNOW1gv1 time series entries.
* @returns {Array<object>} Time series with hourly gaps filled using previous entry data.
*/
#fillInGaps (data) {
if (data.length === 0) return [];
const result = [];
result.push(data[0]); // Keep first data point
result.push(data[0]);
for (let i = 1; i < data.length; i++) {
const from = new Date(data[i - 1].validTime);
const to = new Date(data[i].validTime);
const from = new Date(data[i - 1].time);
const to = new Date(data[i].time);
const hours = Math.floor((to - from) / (1000 * 60 * 60));
// Fill gaps with previous data point (start at j=1 since j=0 is already pushed)
@@ -301,7 +356,7 @@ class SMHIProvider {
const current = { ...data[i - 1] };
const newTime = new Date(from);
newTime.setHours(from.getHours() + j);
current.validTime = newTime.toISOString();
current.time = newTime.toISOString();
result.push(current);
}
@@ -312,13 +367,21 @@ class SMHIProvider {
return result;
}
/**
* Extract coordinates from SNOW1gv1 response.
* SNOW1gv1 returns flat GeoJSON Point: { coordinates: [lon, lat] }
* PMP3gv2 returned nested: { coordinates: [[lon, lat]] }
* @param {object} data - The full SNOW1gv1 API response object.
* @returns {object} Object with lat and lon properties.
*/
#resolveCoordinates (data) {
// SMHI returns coordinates in [lon, lat] format
// Fall back to config if response structure is unexpected
if (data?.geometry?.coordinates?.[0] && Array.isArray(data.geometry.coordinates[0]) && data.geometry.coordinates[0].length >= 2) {
const coords = data?.geometry?.coordinates;
if (Array.isArray(coords) && coords.length >= 2 && typeof coords[0] === "number") {
// SNOW1gv1 flat format: [lon, lat]
return {
lat: data.geometry.coordinates[0][1],
lon: data.geometry.coordinates[0][0]
lat: coords[1],
lon: coords[0]
};
}
@@ -329,20 +392,57 @@ class SMHIProvider {
};
}
/**
* Calculate apparent (feels-like) temperature using humidity and wind.
* Uses SNOW1gv1 parameter names.
* @param {object} weatherData - A single SNOW1gv1 time series entry.
* @returns {number|null} Apparent temperature in °C, or raw temperature if data is missing.
*/
#calculateApparentTemperature (weatherData) {
const Ta = this.#paramValue(weatherData, "t");
const rh = this.#paramValue(weatherData, "r");
const ws = this.#paramValue(weatherData, "ws");
const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
const Ta = this.#paramValue(weatherData, "air_temperature");
const rh = this.#paramValue(weatherData, "relative_humidity");
const ws = this.#paramValue(weatherData, "wind_speed");
if (Ta === null || rh === null || ws === null) {
return Ta; // Fallback to raw temperature if data missing
}
const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return Ta + 0.33 * p - 0.7 * ws - 4;
}
/**
* Get parameter value from SNOW1gv1 flat data structure.
* SNOW1gv1: weatherData.data.parameter_name (direct property access)
* PMP3gv2 used: weatherData.parameters.find(p => p.name === name).values[0]
*
* Returns null if parameter missing or equals SMHI missing value (9999).
* @param {object} weatherData - A single SNOW1gv1 time series entry.
* @param {string} name - The SNOW1gv1 parameter name to look up.
* @returns {number|null} The parameter value, or null if missing.
*/
#paramValue (weatherData, name) {
const param = weatherData.parameters.find((p) => p.name === name);
return param ? param.values[0] : null;
const value = weatherData.data?.[name];
if (value === undefined || value === null) {
return null;
}
// SMHI uses 9999 as missing value sentinel for all parameters
if (value === 9999) {
return null;
}
return value;
}
/**
* Convert SMHI symbol_code (1-27) to MagicMirror weather icon names.
* Symbol codes are identical between PMP3gv2 and SNOW1gv1.
* @param {number} input - SMHI symbol_code value (1-27).
* @param {boolean} isDayTime - Whether the current time is during daytime.
* @returns {string|null} MagicMirror weather icon name, or null if unknown.
*/
#convertWeatherType (input, isDayTime) {
switch (input) {
case 1:
@@ -387,10 +487,16 @@ class SMHIProvider {
}
}
/**
* Build SNOW1gv1 forecast URL.
* Changed from: pmp3g/version/2
* Changed to: snow1g/version/1
* @returns {string} The full SNOW1gv1 API URL for the configured coordinates.
*/
#getUrl () {
const lon = this.config.lon.toFixed(6);
const lat = this.config.lat.toFixed(6);
return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`;
return `https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/${lon}/lat/${lat}/data.json`;
}
}

View File

@@ -351,7 +351,7 @@ class WeatherAPIProvider {
weather.precipitationProbability = precipitationProbability;
}
weather.uv_index = this.#toNumber(forecastDay.day?.uv);
weather.uvIndex = this.#toNumber(forecastDay.day?.uv);
days.push(weather);
@@ -410,7 +410,7 @@ class WeatherAPIProvider {
const willSnow = this.#toNumber(hourData.will_it_snow) ?? 0;
weather.precipitationProbability = (willRain + willSnow) * 50;
weather.uv_index = this.#toNumber(hourData.uv);
weather.uvIndex = this.#toNumber(hourData.uv);
hours.push(weather);

View File

@@ -145,12 +145,15 @@ class WeatherFlowProvider {
const weather = {
date: new Date(),
humidity: current.relative_humidity || null,
temperature: current.air_temperature || null,
feelsLikeTemp: current.feels_like || null,
humidity: current.relative_humidity ?? null,
temperature: current.air_temperature ?? null,
feelsLikeTemp: current.feels_like ?? null,
windSpeed: current.wind_avg != null ? convertKmhToMs(current.wind_avg) : null,
windFromDirection: current.wind_direction || null,
windFromDirection: current.wind_direction ?? null,
weatherType: this.#convertWeatherType(current.icon),
precipitationAmount: current.precip_accum_local_day ?? null,
precipitationUnits: "mm",
precipitationProbability: current.precip_probability ?? null,
uvIndex: current.uv || null,
sunrise: daily.sunrise ? new Date(daily.sunrise * 1000) : null,
sunset: daily.sunset ? new Date(daily.sunset * 1000) : null
@@ -175,9 +178,9 @@ class WeatherFlowProvider {
for (const forecast of data.forecast.daily) {
const weather = {
date: new Date(forecast.day_start_local * 1000),
minTemperature: forecast.air_temp_low || null,
maxTemperature: forecast.air_temp_high || null,
precipitationProbability: forecast.precip_probability || null,
minTemperature: forecast.air_temp_low ?? null,
maxTemperature: forecast.air_temp_high ?? null,
precipitationProbability: forecast.precip_probability ?? null,
weatherType: this.#convertWeatherType(forecast.icon),
precipitationAmount: 0.0,
precipitationUnits: "mm",
@@ -193,8 +196,8 @@ class WeatherFlowProvider {
if (hourDate.getFullYear() === forecastDate.getFullYear()
&& hourDate.getMonth() === forecastDate.getMonth()
&& hourDate.getDate() === forecastDate.getDate()) {
weather.uvIndex = Math.max(weather.uvIndex, hour.uv || 0);
weather.precipitationAmount += hour.precip || 0;
weather.uvIndex = Math.max(weather.uvIndex, hour.uv ?? 0);
weather.precipitationAmount += hour.precip ?? 0;
} else if (hourDate > forecastDate) {
// Check if we've moved to the next day
const diffMs = hourDate - forecastDate;
@@ -224,14 +227,14 @@ class WeatherFlowProvider {
for (const hour of data.forecast.hourly) {
const weather = {
date: new Date(hour.time * 1000),
temperature: hour.air_temperature || null,
feelsLikeTemp: hour.feels_like || null,
humidity: hour.relative_humidity || null,
temperature: hour.air_temperature ?? null,
feelsLikeTemp: hour.feels_like ?? null,
humidity: hour.relative_humidity ?? null,
windSpeed: hour.wind_avg != null ? convertKmhToMs(hour.wind_avg) : null,
windFromDirection: hour.wind_direction || null,
windFromDirection: hour.wind_direction ?? null,
weatherType: this.#convertWeatherType(hour.icon),
precipitationProbability: hour.precip_probability || null,
precipitationAmount: hour.precip || 0,
precipitationProbability: hour.precip_probability ?? null,
precipitationAmount: hour.precip ?? 0,
precipitationUnits: "mm",
uvIndex: hour.uv || null
};

View File

@@ -314,7 +314,7 @@ class YrProvider {
// Convert collected data to forecast objects
const days = [];
for (const [dateStr, data] of dailyData) {
for (const data of dailyData.values()) {
const stellarInfo = this.#getStellarInfoForDate(data.date);
const dayData = {

View File

@@ -1,11 +1,11 @@
/* global WeatherProvider, WeatherUtils, WeatherObject, formatTime */
/* global 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)
type: "current", // current, forecast, daily (equivalent to forecast), hourly
lang: config.language,
units: config.units,
tempUnits: config.units,
@@ -110,8 +110,8 @@ Module.register("weather", {
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()}`;
// All providers run server-side: use stable identifier so reconnects don't spawn duplicate HTTPFetchers
this.instanceId = this.identifier;
if (window.initWeatherTheme) window.initWeatherTheme(this);
@@ -242,7 +242,23 @@ Module.register("weather", {
// Add all the data to the template.
getTemplateData () {
const hourlyData = this.weatherHourlyArray?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1);
const now = new Date();
// Filter out past entries, but keep the current hour (e.g. show 0:00 at 0:10).
// This ensures consistent behavior across all providers, regardless of whether
// a provider filters past entries itself.
const startOfHour = new Date(now);
startOfHour.setMinutes(0, 0, 0);
const upcomingHourlyData = this.weatherHourlyArray
?.filter((entry) => entry.date?.valueOf() >= startOfHour.getTime());
const hourlySourceData = upcomingHourlyData?.length ? upcomingHourlyData : this.weatherHourlyArray;
const increment = this.config.hourlyForecastIncrements;
const keepByConfiguredIncrement = (_entry, index) => {
// Keep the existing offset behavior of hourlyForecastIncrements.
return (index + 1) % increment === increment - 1;
};
const hourlyData = hourlySourceData?.filter(keepByConfiguredIncrement);
return {
config: this.config,