mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-05-09 14:18:33 +00:00
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user