Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b742e839be |
6
.github/workflows/automated-tests.yaml
vendored
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
code-style-check:
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x, 24.x, 25.x]
|
||||
node-version: [22.21.1, 22.x, 24.x]
|
||||
steps:
|
||||
- name: Install electron dependencies and labwc
|
||||
run: |
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox
|
||||
# Start labwc
|
||||
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman labwc &
|
||||
touch config/custom.css
|
||||
touch css/custom.css
|
||||
- name: "Run tests"
|
||||
run: |
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
|
||||
2
.github/workflows/dep-review.yaml
vendored
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v6
|
||||
|
||||
4
.github/workflows/electron-rebuild.yaml
vendored
@@ -5,10 +5,10 @@ on: [pull_request]
|
||||
jobs:
|
||||
rebuild:
|
||||
name: Run electron-rebuild
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x, 24.x, 25.x]
|
||||
node-version: [22.21.1, 22.x, 24.x]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
|
||||
2
.github/workflows/release-notes.yaml
vendored
@@ -15,7 +15,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
2
.github/workflows/spellcheck.yaml
vendored
@@ -12,7 +12,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
spellcheck:
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
2
.github/workflows/stale.yaml
vendored
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
|
||||
13
.gitignore
vendored
@@ -54,13 +54,20 @@ Temporary Items
|
||||
.directory
|
||||
.Trash-*
|
||||
|
||||
# Ignore all modules
|
||||
# Ignore all modules except the default modules.
|
||||
/modules/*
|
||||
!/modules/default
|
||||
|
||||
# Ignore users config file but keep the samples.
|
||||
# Ignore changes to the custom css files but keep the sample and main.
|
||||
/css/*
|
||||
!/css/custom.css.sample
|
||||
!/css/font-awesome.css
|
||||
!/css/main.css
|
||||
!/css/roboto.css
|
||||
|
||||
# Ignore users config file but keep the sample.
|
||||
config
|
||||
!config/config.js.sample
|
||||
!config/custom.css.sample
|
||||
|
||||
# Vim
|
||||
## swap
|
||||
|
||||
@@ -46,7 +46,6 @@ Are done by
|
||||
- [ ] add label `mastermerge`
|
||||
- [ ] title of the PR is `Release 2.xx.0`
|
||||
- [ ] description of the PR is the body of the draft release with name `v2.xx.0`
|
||||
- [ ] check if new PR has merge conflicts, if so, merge `master` into the new PR and solve the conflicts
|
||||
- [ ] after PR tests run without issues, merge PR
|
||||
- [ ] edit draft release with name `v2.xx.0`
|
||||
- [ ] set corresponding version tag `v2.xx.0` (with `Select tag` and then `Create new tag`)
|
||||
@@ -62,24 +61,11 @@ Are done by
|
||||
|
||||
### After release
|
||||
|
||||
- [ ] publish release notes with link to github release on forum in new locked topic (use edit release on github to copy the content with markdown syntax)
|
||||
- [ ] publish release notes with link to github release on forum in new locked topic
|
||||
- [ ] close all issues with label `ready (coming with next release)`
|
||||
- [ ] release new documentation by merging `develop` on `master` in documentation repository
|
||||
- [ ] publish new version on [npm](https://www.npmjs.com/package/magicmirror)
|
||||
- [ ] use a clean environment (e.g. container)
|
||||
- [ ] clone this repository with the new `master` branch and `cd` into the local repository directory
|
||||
- [ ] **Method 1 (recommended): With browser and 2FA**
|
||||
- [ ] execute `npm login` which will open a browser window
|
||||
- [ ] log in with your npm credentials and enter your 2FA code
|
||||
- [ ] execute `npm publish`
|
||||
- [ ] **Method 2 (fallback for headless environments): With token (bypasses 2FA)**
|
||||
- [ ] ⚠️ Note: This method bypasses 2FA and should only be used when a browser is not available
|
||||
- [ ] goto `https://www.npmjs.com/settings/<username>/tokens/` and click `generate new token`
|
||||
- [ ] enable `Bypass two-factor authentication (2FA)` and under `Packages and scopes` give `Read and write` permission to the `magicmirror` package, press `Generate token`
|
||||
- [ ] execute:
|
||||
|
||||
```bash
|
||||
NPM_TOKEN="npm_xxxxxx"
|
||||
npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN"
|
||||
npm publish
|
||||
```
|
||||
- [ ] log in to npm with `npm login --auth-type legacy` which will ask for username and password and one-time-password which is sent via mail
|
||||
- [ ] execute `npm publish`
|
||||
|
||||
@@ -51,10 +51,5 @@ If we receive enough donations we might even be able to free up some working hou
|
||||
To donate, please follow [this](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=G5D8E9MR5DTD2&source=url) link.
|
||||
|
||||
<p style="text-align: center">
|
||||
<a href="https://forum.magicmirror.builders/topic/728/magicmirror-is-voted-number-1-in-the-magpi-top-50">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://magicmirror.builders/img/magpi-best-watermark.png">
|
||||
<img src="https://magicmirror.builders/img/magpi-best-watermark-custom.png" width="150" alt="MagPi Top 50">
|
||||
</picture>
|
||||
</a>
|
||||
<a href="https://forum.magicmirror.builders/topic/728/magicmirror-is-voted-number-1-in-the-magpi-top-50"><img src="https://magicmirror.builders/img/magpi-best-watermark-custom.png" width="150" alt="MagPi Top 50"></a>
|
||||
</p>
|
||||
|
||||
@@ -1,167 +1,136 @@
|
||||
"use strict";
|
||||
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
|
||||
/**
|
||||
* Get command line parameters
|
||||
* Assumes that a cmdline parameter is defined with `--key [value]`
|
||||
*
|
||||
* example: `node clientonly --address localhost --port 8080 --use-tls`
|
||||
* @param {string} key key to look for at the command line
|
||||
* @param {string} defaultValue value if no key is given at the command line
|
||||
* @returns {string} the value of the parameter
|
||||
*/
|
||||
function getCommandLineParameter (key, defaultValue = undefined) {
|
||||
const index = process.argv.indexOf(`--${key}`);
|
||||
const value = index > -1 ? process.argv[index + 1] : undefined;
|
||||
return value !== undefined ? String(value) : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get server address/hostname from either the commandline or env
|
||||
* @returns {object} config object containing address, port, and tls properties
|
||||
*/
|
||||
function getServerParameters () {
|
||||
// Use separate scope to prevent global scope pollution
|
||||
(function () {
|
||||
const config = {};
|
||||
|
||||
// Prefer command line arguments over environment variables
|
||||
config.address = getCommandLineParameter("address", process.env.ADDRESS);
|
||||
const portValue = getCommandLineParameter("port", process.env.PORT);
|
||||
config.port = portValue ? parseInt(portValue, 10) : undefined;
|
||||
/**
|
||||
* Helper function to get server address/hostname from either the commandline or env
|
||||
*/
|
||||
function getServerAddress () {
|
||||
|
||||
// determine if "--use-tls"-flag was provided
|
||||
config.tls = process.argv.includes("--use-tls");
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the config from the specified server url
|
||||
* @param {string} url location where the server is running.
|
||||
* @returns {Promise} the config
|
||||
*/
|
||||
function getServerConfig (url) {
|
||||
// Return new pending promise
|
||||
return new Promise((resolve, reject) => {
|
||||
// Select http or https module, depending on requested url
|
||||
const lib = url.startsWith("https") ? https : http;
|
||||
const request = lib.get(url, (response) => {
|
||||
let configData = "";
|
||||
|
||||
// Gather incoming data
|
||||
response.on("data", function (chunk) {
|
||||
configData += chunk;
|
||||
});
|
||||
// Resolve promise at the end of the HTTP/HTTPS stream
|
||||
response.on("end", function () {
|
||||
try {
|
||||
resolve(JSON.parse(configData));
|
||||
} catch (parseError) {
|
||||
reject(new Error(`Failed to parse server response as JSON: ${parseError.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on("error", function (error) {
|
||||
reject(new Error(`Unable to read config from server (${url}) (${error.message})`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a message to the console in case of errors
|
||||
* @param {string} message error message to print
|
||||
* @param {number} code error code for the exit call
|
||||
*/
|
||||
function fail (message, code = 1) {
|
||||
if (message !== undefined && typeof message === "string") {
|
||||
console.error(message);
|
||||
} else {
|
||||
console.error("Usage: 'node clientonly --address 192.168.1.10 --port 8080 [--use-tls]'");
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the client by connecting to the server and launching the Electron application
|
||||
* @param {object} config server configuration
|
||||
* @param {string} prefix http or https prefix
|
||||
* @async
|
||||
*/
|
||||
async function startClient (config, prefix) {
|
||||
try {
|
||||
const serverUrl = `${prefix}${config.address}:${config.port}/config/`;
|
||||
console.log(`Client: Connecting to server at ${serverUrl}`);
|
||||
const configReturn = await getServerConfig(serverUrl);
|
||||
console.log("Client: Successfully retrieved config from server");
|
||||
|
||||
// check environment for DISPLAY or WAYLAND_DISPLAY
|
||||
const elecParams = ["js/electron.js"];
|
||||
if (process.env.WAYLAND_DISPLAY) {
|
||||
console.log(`Client: Using WAYLAND_DISPLAY=${process.env.WAYLAND_DISPLAY}`);
|
||||
elecParams.push("--enable-features=UseOzonePlatform");
|
||||
elecParams.push("--ozone-platform=wayland");
|
||||
} else if (process.env.DISPLAY) {
|
||||
console.log(`Client: Using DISPLAY=${process.env.DISPLAY}`);
|
||||
} else {
|
||||
fail("Error: Requires environment variable WAYLAND_DISPLAY or DISPLAY, none is provided.");
|
||||
/**
|
||||
* Get command line parameters
|
||||
* Assumes that a cmdline parameter is defined with `--key [value]`
|
||||
* @param {string} key key to look for at the command line
|
||||
* @param {string} defaultValue value if no key is given at the command line
|
||||
* @returns {string} the value of the parameter
|
||||
*/
|
||||
function getCommandLineParameter (key, defaultValue = undefined) {
|
||||
const index = process.argv.indexOf(`--${key}`);
|
||||
const value = index > -1 ? process.argv[index + 1] : undefined;
|
||||
return value !== undefined ? String(value) : defaultValue;
|
||||
}
|
||||
|
||||
// Pass along the server config via an environment variable
|
||||
const env = { ...process.env };
|
||||
env.clientonly = true;
|
||||
const options = { env: env };
|
||||
configReturn.address = config.address;
|
||||
configReturn.port = config.port;
|
||||
configReturn.tls = config.tls;
|
||||
env.config = JSON.stringify(configReturn);
|
||||
|
||||
// Spawn electron application
|
||||
const electron = require("electron");
|
||||
const child = require("node:child_process").spawn(electron, elecParams, options);
|
||||
|
||||
// Pipe all child process output to current stdout
|
||||
child.stdout.on("data", function (buf) {
|
||||
process.stdout.write(`Client: ${buf}`);
|
||||
// Prefer command line arguments over environment variables
|
||||
["address", "port"].forEach((key) => {
|
||||
config[key] = getCommandLineParameter(key, process.env[key.toUpperCase()]);
|
||||
});
|
||||
|
||||
// Pipe all child process errors to current stderr
|
||||
child.stderr.on("data", function (buf) {
|
||||
process.stderr.write(`Client: ${buf}`);
|
||||
});
|
||||
|
||||
child.on("error", function (err) {
|
||||
process.stderr.write(`Client: ${err}`);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
fail(`There is something wrong. The clientonly process exited with code ${code}.`);
|
||||
}
|
||||
});
|
||||
} catch (reason) {
|
||||
fail(`Unable to connect to server: (${reason})`);
|
||||
// determine if "--use-tls"-flag was provided
|
||||
config.tls = process.argv.indexOf("--use-tls") > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const config = getServerParameters();
|
||||
const prefix = config.tls ? "https://" : "http://";
|
||||
/**
|
||||
* Gets the config from the specified server url
|
||||
* @param {string} url location where the server is running.
|
||||
* @returns {Promise} the config
|
||||
*/
|
||||
function getServerConfig (url) {
|
||||
// Return new pending promise
|
||||
return new Promise((resolve, reject) => {
|
||||
// Select http or https module, depending on requested url
|
||||
const lib = url.startsWith("https") ? require("node:https") : require("node:http");
|
||||
const request = lib.get(url, (response) => {
|
||||
let configData = "";
|
||||
|
||||
// Validate port
|
||||
if (config.port !== undefined && (isNaN(config.port) || config.port < 1 || config.port > 65535)) {
|
||||
fail(`Invalid port number: ${config.port}. Port must be between 1 and 65535.`);
|
||||
}
|
||||
// Gather incoming data
|
||||
response.on("data", function (chunk) {
|
||||
configData += chunk;
|
||||
});
|
||||
// Resolve promise at the end of the HTTP/HTTPS stream
|
||||
response.on("end", function () {
|
||||
resolve(JSON.parse(configData));
|
||||
});
|
||||
});
|
||||
|
||||
// Only start the client if a non-local server was provided and address/port are set
|
||||
const LOCAL_ADDRESSES = ["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1"];
|
||||
if (
|
||||
config.address
|
||||
&& config.port
|
||||
&& !LOCAL_ADDRESSES.includes(config.address)
|
||||
) {
|
||||
startClient(config, prefix);
|
||||
} else {
|
||||
fail();
|
||||
}
|
||||
request.on("error", function (error) {
|
||||
reject(new Error(`Unable to read config from server (${url} (${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a message to the console in case of errors
|
||||
* @param {string} message error message to print
|
||||
* @param {number} code error code for the exit call
|
||||
*/
|
||||
function fail (message, code = 1) {
|
||||
if (message !== undefined && typeof message === "string") {
|
||||
console.log(message);
|
||||
} else {
|
||||
console.log("Usage: 'node clientonly --address 192.168.1.10 --port 8080 [--use-tls]'");
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
getServerAddress();
|
||||
|
||||
(config.address && config.port) || fail();
|
||||
const prefix = config.tls ? "https://" : "http://";
|
||||
|
||||
// Only start the client if a non-local server was provided
|
||||
if (["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1", undefined].indexOf(config.address) === -1) {
|
||||
getServerConfig(`${prefix}${config.address}:${config.port}/config/`)
|
||||
.then(function (configReturn) {
|
||||
// check environment for DISPLAY or WAYLAND_DISPLAY
|
||||
const elecParams = ["js/electron.js"];
|
||||
if (process.env.WAYLAND_DISPLAY) {
|
||||
console.log(`Client: Using WAYLAND_DISPLAY=${process.env.WAYLAND_DISPLAY}`);
|
||||
elecParams.push("--enable-features=UseOzonePlatform");
|
||||
elecParams.push("--ozone-platform=wayland");
|
||||
} else if (process.env.DISPLAY) {
|
||||
console.log(`Client: Using DISPLAY=${process.env.DISPLAY}`);
|
||||
} else {
|
||||
fail("Error: Requires environment variable WAYLAND_DISPLAY or DISPLAY, none is provided.");
|
||||
}
|
||||
// Pass along the server config via an environment variable
|
||||
const env = Object.create(process.env);
|
||||
env.clientonly = true; // set to pass to electron.js
|
||||
const options = { env: env };
|
||||
configReturn.address = config.address;
|
||||
configReturn.port = config.port;
|
||||
configReturn.tls = config.tls;
|
||||
env.config = JSON.stringify(configReturn);
|
||||
|
||||
// Spawn electron application
|
||||
const electron = require("electron");
|
||||
const child = require("node:child_process").spawn(electron, elecParams, options);
|
||||
|
||||
// Pipe all child process output to current stdout
|
||||
child.stdout.on("data", function (buf) {
|
||||
process.stdout.write(`Client: ${buf}`);
|
||||
});
|
||||
|
||||
// Pipe all child process errors to current stderr
|
||||
child.stderr.on("data", function (buf) {
|
||||
process.stderr.write(`Client: ${buf}`);
|
||||
});
|
||||
|
||||
child.on("error", function (err) {
|
||||
process.stdout.write(`Client: ${err}`);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
console.log(`There something wrong. The clientonly is not running code ${code}`);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function (reason) {
|
||||
fail(`Unable to connect to server: (${reason})`);
|
||||
});
|
||||
} else {
|
||||
fail();
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -179,7 +179,6 @@
|
||||
"Lightspeed",
|
||||
"loadingcircle",
|
||||
"locationforecast",
|
||||
"logg",
|
||||
"lockstring",
|
||||
"lstrip",
|
||||
"Luciella",
|
||||
@@ -248,7 +247,6 @@
|
||||
"Reis",
|
||||
"rejas",
|
||||
"relativehumidity",
|
||||
"resultstring",
|
||||
"Resig",
|
||||
"roboto",
|
||||
"rohitdharavath",
|
||||
@@ -287,9 +285,6 @@
|
||||
"Teeuw",
|
||||
"Teil",
|
||||
"TESTMODE",
|
||||
"testpass",
|
||||
"testuser",
|
||||
"teststring",
|
||||
"thomasrockhu",
|
||||
"thumbslider",
|
||||
"timeformat",
|
||||
@@ -312,7 +307,6 @@
|
||||
"VEVENT",
|
||||
"vgtu",
|
||||
"Vitest",
|
||||
"VCALENDAR",
|
||||
"Voelt",
|
||||
"Vorberechnung",
|
||||
"vppencilsharpener",
|
||||
@@ -332,7 +326,6 @@
|
||||
"winddirection",
|
||||
"windgusts",
|
||||
"windspeed",
|
||||
"WKST",
|
||||
"Woolridge",
|
||||
"worktree",
|
||||
"Wsymb",
|
||||
@@ -350,11 +343,11 @@
|
||||
"ignorePaths": [
|
||||
"css/roboto.css",
|
||||
"node_modules/**",
|
||||
"modules/**",
|
||||
"defaultmodules/**/translations/!(en).json",
|
||||
"defaultmodules/calendar/windowsZones.json",
|
||||
"defaultmodules/clock/faces/*.svg",
|
||||
"defaultmodules/weather/providers/yr.js",
|
||||
"modules/!(default)/**",
|
||||
"modules/default/**/translations/!(en).json",
|
||||
"modules/default/calendar/windowsZones.json",
|
||||
"modules/default/clock/faces/*.svg",
|
||||
"modules/default/weather/providers/yr.js",
|
||||
"tests/mocks/**",
|
||||
"tests/e2e/modules/clock_es_spec.js",
|
||||
"translations/**"
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
const ical = require("node-ical");
|
||||
const Log = require("logger");
|
||||
const { Agent } = require("undici");
|
||||
const CalendarFetcherUtils = require("./calendarfetcherutils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* CalendarFetcher - Fetches and parses iCal calendar data
|
||||
* Uses HTTPFetcher for HTTP handling with intelligent error handling
|
||||
* @class
|
||||
*/
|
||||
class CalendarFetcher {
|
||||
|
||||
/**
|
||||
* Creates a new CalendarFetcher instance
|
||||
* @param {string} url - The URL of the calendar to fetch
|
||||
* @param {number} reloadInterval - Time in ms between fetches
|
||||
* @param {string[]} excludedEvents - Event titles to exclude
|
||||
* @param {number} maximumEntries - Maximum number of events to return
|
||||
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
|
||||
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
|
||||
* @param {boolean} includePastEvents - Whether to include past events
|
||||
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
|
||||
*/
|
||||
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
|
||||
this.url = url;
|
||||
this.excludedEvents = excludedEvents;
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.maximumNumberOfDays = maximumNumberOfDays;
|
||||
this.includePastEvents = includePastEvents;
|
||||
|
||||
this.events = [];
|
||||
this.lastFetch = null;
|
||||
this.fetchFailedCallback = () => {};
|
||||
this.eventsReceivedCallback = () => {};
|
||||
|
||||
// Use HTTPFetcher for HTTP handling (Composition)
|
||||
this.httpFetcher = new HTTPFetcher(url, {
|
||||
reloadInterval,
|
||||
auth,
|
||||
selfSignedCert
|
||||
});
|
||||
|
||||
// Wire up HTTPFetcher events
|
||||
this.httpFetcher.on("response", (response) => this.#handleResponse(response));
|
||||
this.httpFetcher.on("error", (errorInfo) => this.fetchFailedCallback(this, errorInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles successful HTTP response
|
||||
* @param {Response} response - The fetch Response object
|
||||
*/
|
||||
async #handleResponse (response) {
|
||||
try {
|
||||
const responseData = await response.text();
|
||||
const parsed = ical.parseICS(responseData);
|
||||
|
||||
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
|
||||
|
||||
this.events = CalendarFetcherUtils.filterEvents(parsed, {
|
||||
excludedEvents: this.excludedEvents,
|
||||
includePastEvents: this.includePastEvents,
|
||||
maximumEntries: this.maximumEntries,
|
||||
maximumNumberOfDays: this.maximumNumberOfDays
|
||||
});
|
||||
|
||||
this.lastFetch = Date.now();
|
||||
this.broadcastEvents();
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, {
|
||||
message: `iCal parsing failed: ${error.message}`,
|
||||
status: null,
|
||||
errorType: "PARSE_ERROR",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED",
|
||||
retryAfter: this.httpFetcher.reloadInterval,
|
||||
retryCount: 0,
|
||||
url: this.url,
|
||||
originalError: error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts fetching calendar data
|
||||
*/
|
||||
fetchCalendar () {
|
||||
this.httpFetcher.startPeriodicFetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enough time has passed since the last fetch to warrant a new one.
|
||||
* Uses reloadInterval as the threshold to respect user's configured fetchInterval.
|
||||
* @returns {boolean} True if a new fetch should be performed
|
||||
*/
|
||||
shouldRefetch () {
|
||||
if (!this.lastFetch) {
|
||||
return true;
|
||||
}
|
||||
const timeSinceLastFetch = Date.now() - this.lastFetch;
|
||||
return timeSinceLastFetch >= this.httpFetcher.reloadInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts the current events to listeners
|
||||
*/
|
||||
broadcastEvents () {
|
||||
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
|
||||
this.eventsReceivedCallback(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback for successful event fetches
|
||||
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
|
||||
*/
|
||||
onReceive (callback) {
|
||||
this.eventsReceivedCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback for fetch failures
|
||||
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
|
||||
*/
|
||||
onError (callback) {
|
||||
this.fetchFailedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CalendarFetcher;
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* @external Moment
|
||||
*/
|
||||
const moment = require("moment-timezone");
|
||||
const ical = require("node-ical");
|
||||
|
||||
const Log = require("logger");
|
||||
|
||||
const CalendarFetcherUtils = {
|
||||
|
||||
/**
|
||||
* Determine based on the title of an event if it should be excluded from the list of events
|
||||
* @param {object} config the global config
|
||||
* @param {string} title the title of the event
|
||||
* @returns {object} excluded: true if the event should be excluded, false otherwise
|
||||
* until: the date until the event should be excluded.
|
||||
*/
|
||||
shouldEventBeExcluded (config, title) {
|
||||
for (const filterConfig of config.excludedEvents) {
|
||||
const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);
|
||||
if (match) {
|
||||
return {
|
||||
excluded: !match.until,
|
||||
until: match.until
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
excluded: false,
|
||||
until: null
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get local timezone.
|
||||
* This method makes it easier to test if different timezones cause problems by changing this implementation.
|
||||
* @returns {string} timezone
|
||||
*/
|
||||
getLocalTimezone () {
|
||||
return moment.tz.guess();
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter the events from ical according to the given config
|
||||
* @param {object} data the calendar data from ical
|
||||
* @param {object} config The configuration object
|
||||
* @returns {object[]} the filtered events
|
||||
*/
|
||||
filterEvents (data, config) {
|
||||
const newEvents = [];
|
||||
|
||||
Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
|
||||
|
||||
const now = moment();
|
||||
const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now;
|
||||
const futureLocalMoment
|
||||
= now
|
||||
.clone()
|
||||
.startOf("day")
|
||||
.add(config.maximumNumberOfDays, "days")
|
||||
// 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]) => {
|
||||
if (event.type !== "VEVENT") {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = CalendarFetcherUtils.getTitleFromEvent(event);
|
||||
Log.debug(`title: ${title}`);
|
||||
|
||||
// Return quickly if event should be excluded.
|
||||
const { excluded, until: eventFilterUntil } = CalendarFetcherUtils.shouldEventBeExcluded(config, title);
|
||||
if (excluded) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug(`Event: ${title} | start: ${event.start} | end: ${event.end} | recurring: ${!!event.rrule}`);
|
||||
|
||||
const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false;
|
||||
const geo = event.geo || false;
|
||||
const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false;
|
||||
|
||||
let instances;
|
||||
try {
|
||||
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment);
|
||||
} catch (error) {
|
||||
Log.error(`Could not expand event "${title}": ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const instance of instances) {
|
||||
const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance;
|
||||
|
||||
// Filter logic
|
||||
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const instanceTitle = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
|
||||
|
||||
Log.debug(`saving event: ${instanceTitle}, start: ${startMoment.toDate()}, end: ${endMoment.toDate()}`);
|
||||
newEvents.push({
|
||||
title: instanceTitle,
|
||||
startDate: startMoment.format("x"),
|
||||
endDate: endMoment.format("x"),
|
||||
fullDayEvent: isFullDay,
|
||||
recurringEvent: isRecurring,
|
||||
class: event.class,
|
||||
firstYear: event.start.getFullYear(),
|
||||
location: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.location) || location,
|
||||
geo: instanceEvent.geo || geo,
|
||||
description: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.description) || description
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
newEvents.sort(function (a, b) {
|
||||
return a.startDate - b.startDate;
|
||||
});
|
||||
|
||||
return newEvents;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the title from the event.
|
||||
* @param {object} event The event object to check.
|
||||
* @returns {string} The title of the event, or "Event" if no title is found.
|
||||
*/
|
||||
getTitleFromEvent (event) {
|
||||
return CalendarFetcherUtils.unwrapParameterValue(event.summary || event.description) || "Event";
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts the string value from a node-ical ParameterValue object ({val, params})
|
||||
* or returns the value as-is if it is already a plain string.
|
||||
* This handles ICS properties with parameters, e.g. DESCRIPTION;LANGUAGE=de:Text.
|
||||
* @param {string|object} value The raw value from node-ical
|
||||
* @returns {string|object} The unwrapped string value, or the original value if not a ParameterValue
|
||||
*/
|
||||
unwrapParameterValue (value) {
|
||||
if (value && typeof value === "object" && typeof value.val !== "undefined") {
|
||||
return value.val;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if the user defined time filter should apply
|
||||
* @param {moment.Moment} now Date object using previously created object for consistency
|
||||
* @param {moment.Moment} endDate Moment object representing the event end date
|
||||
* @param {string} filter The time to subtract from the end date to determine if an event should be shown
|
||||
* @returns {boolean} True if the event should be filtered out, false otherwise
|
||||
*/
|
||||
timeFilterApplies (now, endDate, filter) {
|
||||
if (filter) {
|
||||
const until = filter.split(" "),
|
||||
value = parseInt(until[0]),
|
||||
increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
|
||||
filterUntil = moment(endDate.format()).subtract(value, increment);
|
||||
|
||||
return now.isBefore(filterUntil);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if the user defined title filter should apply
|
||||
* @param {string} title the title of the event
|
||||
* @param {string} filter the string to look for, can be a regex also
|
||||
* @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string
|
||||
* @param {string} regexFlags flags that should be applied to the regex
|
||||
* @returns {boolean} True if the title should be filtered out, false otherwise
|
||||
*/
|
||||
titleFilterApplies (title, filter, useRegex, regexFlags) {
|
||||
if (useRegex) {
|
||||
let regexFilter = filter;
|
||||
// Assume if leading slash, there is also trailing slash
|
||||
if (filter[0] === "/") {
|
||||
// Strip leading and trailing slashes
|
||||
regexFilter = filter.slice(1, -1);
|
||||
}
|
||||
return new RegExp(regexFilter, regexFlags).test(title);
|
||||
} else {
|
||||
return title.includes(filter);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Expands a recurring event into individual event instances using node-ical.
|
||||
* Handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events.
|
||||
* @param {object} event The recurring event object
|
||||
* @param {moment.Moment} pastLocalMoment The past date limit
|
||||
* @param {moment.Moment} futureLocalMoment The future date limit
|
||||
* @returns {object[]} Array of event instances with startMoment/endMoment in the local timezone
|
||||
*/
|
||||
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment) {
|
||||
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
|
||||
|
||||
return ical
|
||||
.expandRecurringEvent(event, {
|
||||
from: pastLocalMoment.toDate(),
|
||||
to: futureLocalMoment.toDate(),
|
||||
includeOverrides: true,
|
||||
excludeExdates: true,
|
||||
expandOngoing: true
|
||||
})
|
||||
.map((inst) => {
|
||||
let startMoment, endMoment;
|
||||
if (inst.isFullDay) {
|
||||
startMoment = moment.tz([inst.start.getFullYear(), inst.start.getMonth(), inst.start.getDate()], localTimezone);
|
||||
endMoment = moment.tz([inst.end.getFullYear(), inst.end.getMonth(), inst.end.getDate()], localTimezone);
|
||||
} else {
|
||||
startMoment = moment(inst.start).tz(localTimezone);
|
||||
endMoment = moment(inst.end).tz(localTimezone);
|
||||
}
|
||||
// Events without DTEND (e.g. reminders) get start === end from node-ical;
|
||||
// extend to end-of-day so they remain visible on the calendar.
|
||||
if (startMoment.valueOf() === endMoment.valueOf()) endMoment = endMoment.endOf("day");
|
||||
return { event: inst.event, startMoment, endMoment, isRecurring: inst.isRecurring, isFullDay: inst.isFullDay };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if an event title matches a specific filter configuration.
|
||||
* @param {string} title The event title to check
|
||||
* @param {string|object} filterConfig The filter configuration (string or object)
|
||||
* @returns {object|null} Object with {until: string|null} if matched, null otherwise
|
||||
*/
|
||||
checkEventAgainstFilter (title, filterConfig) {
|
||||
let filter = filterConfig;
|
||||
let testTitle = title.toLowerCase();
|
||||
let until = null;
|
||||
let useRegex = false;
|
||||
let regexFlags = "g";
|
||||
|
||||
if (filter instanceof Object) {
|
||||
if (typeof filter.until !== "undefined") {
|
||||
until = filter.until;
|
||||
}
|
||||
|
||||
if (typeof filter.regex !== "undefined") {
|
||||
useRegex = filter.regex;
|
||||
}
|
||||
|
||||
if (filter.caseSensitive) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
} else if (useRegex) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
regexFlags += "i";
|
||||
} else {
|
||||
filter = filter.filterBy.toLowerCase();
|
||||
}
|
||||
} else {
|
||||
filter = filter.toLowerCase();
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
|
||||
return { until };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = CalendarFetcherUtils;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
.newsfeed-fullarticle-container {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
z-index: 1000;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.newsfeed-fullarticle-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
iframe.newsfeed-fullarticle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 5000px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.region.bottom.bar.newsfeed-fullarticle {
|
||||
bottom: inherit;
|
||||
top: -90px;
|
||||
}
|
||||
|
||||
.newsfeed-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.newsfeed-list li {
|
||||
text-align: justify;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
const crypto = require("node:crypto");
|
||||
const stream = require("node:stream");
|
||||
const FeedMe = require("feedme");
|
||||
const iconv = require("iconv-lite");
|
||||
const { htmlToText } = require("html-to-text");
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* NewsfeedFetcher - Fetches and parses RSS/Atom feed data
|
||||
* Uses HTTPFetcher for HTTP handling with intelligent error handling
|
||||
* @class
|
||||
*/
|
||||
class NewsfeedFetcher {
|
||||
|
||||
/**
|
||||
* Creates a new NewsfeedFetcher instance
|
||||
* @param {string} url - The URL of the news feed to fetch
|
||||
* @param {number} reloadInterval - Time in ms between fetches
|
||||
* @param {string} encoding - Encoding of the feed (e.g., 'UTF-8')
|
||||
* @param {boolean} logFeedWarnings - If true log warnings when there is an error parsing a news article
|
||||
* @param {boolean} useCorsProxy - If true cors proxy is used for article url's
|
||||
*/
|
||||
constructor (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy) {
|
||||
this.url = url;
|
||||
this.encoding = encoding;
|
||||
this.logFeedWarnings = logFeedWarnings;
|
||||
this.useCorsProxy = useCorsProxy;
|
||||
this.items = [];
|
||||
this.fetchFailedCallback = () => {};
|
||||
this.itemsReceivedCallback = () => {};
|
||||
|
||||
// Use HTTPFetcher for HTTP handling (Composition)
|
||||
this.httpFetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: Math.max(reloadInterval, 1000),
|
||||
headers: {
|
||||
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
|
||||
Pragma: "no-cache"
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up HTTPFetcher events
|
||||
this.httpFetcher.on("response", (response) => this.#handleResponse(response));
|
||||
this.httpFetcher.on("error", (errorInfo) => this.fetchFailedCallback(this, errorInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a parse error info object
|
||||
* @param {string} message - Error message
|
||||
* @param {Error} error - Original error
|
||||
* @returns {object} Error info object
|
||||
*/
|
||||
#createParseError (message, error) {
|
||||
return {
|
||||
message,
|
||||
status: null,
|
||||
errorType: "PARSE_ERROR",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED",
|
||||
retryAfter: this.httpFetcher.reloadInterval,
|
||||
retryCount: 0,
|
||||
url: this.url,
|
||||
originalError: error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles successful HTTP response
|
||||
* @param {Response} response - The fetch Response object
|
||||
*/
|
||||
#handleResponse (response) {
|
||||
this.items = [];
|
||||
const parser = new FeedMe();
|
||||
|
||||
parser.on("item", (item) => {
|
||||
const title = item.title;
|
||||
let description = item.description || item.summary || item.content || "";
|
||||
const pubdate = item.pubdate || item.published || item.updated || item["dc:date"] || item["a10:updated"];
|
||||
const url = item.url || item.link || "";
|
||||
|
||||
if (title && pubdate) {
|
||||
// Convert HTML entities, codes and tag
|
||||
description = htmlToText(description, {
|
||||
wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: "a", options: { ignoreHref: true, noAnchorUrl: true } },
|
||||
{ selector: "br", format: "inlineSurround", options: { prefix: " " } },
|
||||
{ selector: "img", format: "skip" }
|
||||
]
|
||||
});
|
||||
|
||||
this.items.push({
|
||||
title,
|
||||
description,
|
||||
pubdate,
|
||||
url,
|
||||
useCorsProxy: this.useCorsProxy,
|
||||
hash: crypto.createHash("sha256").update(`${pubdate} :: ${title} :: ${url}`).digest("hex")
|
||||
});
|
||||
} else if (this.logFeedWarnings) {
|
||||
Log.warn("Can't parse feed item:", item);
|
||||
Log.warn(`Title: ${title}`);
|
||||
Log.warn(`Description: ${description}`);
|
||||
Log.warn(`Pubdate: ${pubdate}`);
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
this.httpFetcher.reloadInterval = ttlms;
|
||||
Log.info(`reloadInterval set to ttl=${ttlms} for url ${this.url}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const nodeStream = response.body instanceof stream.Readable
|
||||
? response.body
|
||||
: stream.Readable.fromWeb(response.body);
|
||||
nodeStream.pipe(iconv.decodeStream(this.encoding)).pipe(parser);
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - Stream processing failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, this.#createParseError(`Stream processing failed: ${error.message}`, error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the reload interval, but only if we need to increase the speed.
|
||||
* @param {number} interval - Interval for the update in milliseconds.
|
||||
*/
|
||||
setReloadInterval (interval) {
|
||||
if (interval > 1000 && interval < this.httpFetcher.reloadInterval) {
|
||||
this.httpFetcher.reloadInterval = interval;
|
||||
}
|
||||
}
|
||||
|
||||
startFetch () {
|
||||
this.httpFetcher.startPeriodicFetch();
|
||||
}
|
||||
|
||||
broadcastItems () {
|
||||
if (this.items.length <= 0) {
|
||||
Log.info("No items to broadcast yet.");
|
||||
return;
|
||||
}
|
||||
Log.info(`Broadcasting ${this.items.length} items.`);
|
||||
this.itemsReceivedCallback(this);
|
||||
}
|
||||
|
||||
/** @param {function(NewsfeedFetcher): void} callback - Called when items are received */
|
||||
onReceive (callback) {
|
||||
this.itemsReceivedCallback = callback;
|
||||
}
|
||||
|
||||
/** @param {function(NewsfeedFetcher, object): void} callback - Called on fetch error */
|
||||
onError (callback) {
|
||||
this.fetchFailedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NewsfeedFetcher;
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Format the time according to the config
|
||||
* @param {object} config The config of the module
|
||||
* @param {object} time time to format
|
||||
* @returns {string} The formatted time string
|
||||
*/
|
||||
const formatTime = (config, time) => {
|
||||
let date = moment(time);
|
||||
|
||||
if (config.timezone) {
|
||||
date = date.tz(config.timezone);
|
||||
}
|
||||
|
||||
if (config.timeFormat !== 24) {
|
||||
if (config.showPeriod) {
|
||||
if (config.showPeriodUpper) {
|
||||
return date.format("h:mm A");
|
||||
} else {
|
||||
return date.format("h:mm a");
|
||||
}
|
||||
} else {
|
||||
return date.format("h:mm");
|
||||
}
|
||||
}
|
||||
|
||||
return date.format("HH:mm");
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") module.exports = {
|
||||
formatTime
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
const path = require("node:path");
|
||||
const NodeHelper = require("node_helper");
|
||||
const Log = require("logger");
|
||||
|
||||
module.exports = NodeHelper.create({
|
||||
providers: {},
|
||||
|
||||
start () {
|
||||
Log.log(`Starting node helper for: ${this.name}`);
|
||||
},
|
||||
|
||||
socketNotificationReceived (notification, payload) {
|
||||
if (notification === "INIT_WEATHER") {
|
||||
Log.log(`Received INIT_WEATHER for instance ${payload.instanceId}`);
|
||||
this.initWeatherProvider(payload);
|
||||
} else if (notification === "STOP_WEATHER") {
|
||||
Log.log(`Received STOP_WEATHER for instance ${payload.instanceId}`);
|
||||
this.stopWeatherProvider(payload.instanceId);
|
||||
}
|
||||
// FETCH_WEATHER is no longer needed - HTTPFetcher handles periodic fetching
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize a weather provider
|
||||
* @param {object} config The configuration object
|
||||
*/
|
||||
async initWeatherProvider (config) {
|
||||
const identifier = config.weatherProvider.toLowerCase();
|
||||
const instanceId = config.instanceId;
|
||||
|
||||
Log.log(`Attempting to initialize provider ${identifier} for instance ${instanceId}`);
|
||||
|
||||
if (this.providers[instanceId]) {
|
||||
Log.log(`Weather provider ${identifier} already initialized for instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamically load the provider module
|
||||
const providerPath = path.join(__dirname, "providers", `${identifier}.js`);
|
||||
Log.log(`Loading provider from: ${providerPath}`);
|
||||
const ProviderClass = require(providerPath);
|
||||
|
||||
// Create provider instance
|
||||
const provider = new ProviderClass(config);
|
||||
|
||||
// Set up callbacks before initializing
|
||||
provider.setCallbacks(
|
||||
(data) => {
|
||||
// On data received
|
||||
this.sendSocketNotification("WEATHER_DATA", {
|
||||
instanceId,
|
||||
type: config.type,
|
||||
data
|
||||
});
|
||||
},
|
||||
(errorInfo) => {
|
||||
// On error
|
||||
this.sendSocketNotification("WEATHER_ERROR", {
|
||||
instanceId,
|
||||
error: errorInfo.message || "Unknown error",
|
||||
translationKey: errorInfo.translationKey
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
this.providers[instanceId] = provider;
|
||||
|
||||
this.sendSocketNotification("WEATHER_INITIALIZED", {
|
||||
instanceId,
|
||||
locationName: provider.locationName
|
||||
});
|
||||
|
||||
// Start periodic fetching
|
||||
provider.start();
|
||||
|
||||
Log.log(`Weather provider ${identifier} initialized for instance ${instanceId}`);
|
||||
} catch (error) {
|
||||
Log.error(`Failed to initialize weather provider ${identifier}:`, error);
|
||||
this.sendSocketNotification("WEATHER_ERROR", {
|
||||
instanceId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop and cleanup a weather provider
|
||||
* @param {string} instanceId The instance identifier
|
||||
*/
|
||||
stopWeatherProvider (instanceId) {
|
||||
const provider = this.providers[instanceId];
|
||||
|
||||
if (provider) {
|
||||
Log.log(`Stopping weather provider for instance ${instanceId}`);
|
||||
provider.stop();
|
||||
delete this.providers[instanceId];
|
||||
} else {
|
||||
Log.warn(`No provider found for instance ${instanceId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Shared utility functions for weather providers
|
||||
*/
|
||||
|
||||
const SunCalc = require("suncalc");
|
||||
|
||||
/**
|
||||
* Convert OpenWeatherMap icon codes to internal weather types
|
||||
* @param {string} weatherType - OpenWeatherMap icon code (e.g., "01d", "02n")
|
||||
* @returns {string|null} Internal weather type
|
||||
*/
|
||||
function convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
"01d": "day-sunny",
|
||||
"02d": "day-cloudy",
|
||||
"03d": "cloudy",
|
||||
"04d": "cloudy-windy",
|
||||
"09d": "showers",
|
||||
"10d": "rain",
|
||||
"11d": "thunderstorm",
|
||||
"13d": "snow",
|
||||
"50d": "fog",
|
||||
"01n": "night-clear",
|
||||
"02n": "night-cloudy",
|
||||
"03n": "night-cloudy",
|
||||
"04n": "night-cloudy",
|
||||
"09n": "night-showers",
|
||||
"10n": "night-rain",
|
||||
"11n": "night-thunderstorm",
|
||||
"13n": "night-snow",
|
||||
"50n": "night-alt-cloudy-windy"
|
||||
};
|
||||
|
||||
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply timezone offset to a date
|
||||
* @param {Date} date - The date to apply offset to
|
||||
* @param {number} offsetMinutes - Timezone offset in minutes
|
||||
* @returns {Date} Date with applied offset
|
||||
*/
|
||||
function applyTimezoneOffset (date, offsetMinutes) {
|
||||
const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000);
|
||||
return new Date(utcTime + (offsetMinutes * 60000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit decimal places for coordinates (truncate, not round)
|
||||
* @param {number} value - The coordinate value
|
||||
* @param {number} decimals - Maximum number of decimal places
|
||||
* @returns {number} Value with limited decimal places
|
||||
*/
|
||||
function limitDecimals (value, decimals) {
|
||||
const str = value.toString();
|
||||
if (str.includes(".")) {
|
||||
const parts = str.split(".");
|
||||
if (parts[1].length > decimals) {
|
||||
return parseFloat(`${parts[0]}.${parts[1].substring(0, decimals)}`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sunrise and sunset times for a given date and location
|
||||
* @param {Date} date - The date to calculate for
|
||||
* @param {number} lat - Latitude
|
||||
* @param {number} lon - Longitude
|
||||
* @returns {object} Object with sunrise and sunset Date objects
|
||||
*/
|
||||
function getSunTimes (date, lat, lon) {
|
||||
const sunTimes = SunCalc.getTimes(date, lat, lon);
|
||||
return {
|
||||
sunrise: sunTimes.sunrise,
|
||||
sunset: sunTimes.sunset
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given time is during daylight hours
|
||||
* @param {Date} date - The date/time to check
|
||||
* @param {Date} sunrise - Sunrise time
|
||||
* @param {Date} sunset - Sunset time
|
||||
* @returns {boolean} True if during daylight hours
|
||||
*/
|
||||
function isDayTime (date, sunrise, sunset) {
|
||||
if (!sunrise || !sunset) {
|
||||
return true; // Default to day if times unavailable
|
||||
}
|
||||
return date >= sunrise && date < sunset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timezone offset as string (e.g., "+01:00", "-05:30")
|
||||
* @param {number} offsetMinutes - Timezone offset in minutes (use -new Date().getTimezoneOffset() for local)
|
||||
* @returns {string} Formatted offset string
|
||||
*/
|
||||
function formatTimezoneOffset (offsetMinutes) {
|
||||
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||
const minutes = Math.abs(offsetMinutes) % 60;
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
return `${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date string in YYYY-MM-DD format (local time)
|
||||
* @param {Date} date - The date to format
|
||||
* @returns {string} Date string in YYYY-MM-DD format
|
||||
*/
|
||||
function getDateString (date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wind speed from km/h to m/s
|
||||
* @param {number} kmh - Wind speed in km/h
|
||||
* @returns {number} Wind speed in m/s
|
||||
*/
|
||||
function convertKmhToMs (kmh) {
|
||||
return kmh / 3.6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert cardinal wind direction string to degrees
|
||||
* @param {string} direction - Cardinal direction (e.g., "N", "NNE", "SW")
|
||||
* @returns {number|null} Direction in degrees (0-360) or null if unknown
|
||||
*/
|
||||
function cardinalToDegrees (direction) {
|
||||
const directions = {
|
||||
N: 0,
|
||||
NNE: 22.5,
|
||||
NE: 45,
|
||||
ENE: 67.5,
|
||||
E: 90,
|
||||
ESE: 112.5,
|
||||
SE: 135,
|
||||
SSE: 157.5,
|
||||
S: 180,
|
||||
SSW: 202.5,
|
||||
SW: 225,
|
||||
WSW: 247.5,
|
||||
W: 270,
|
||||
WNW: 292.5,
|
||||
NW: 315,
|
||||
NNW: 337.5
|
||||
};
|
||||
return directions[direction] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and limit coordinate precision
|
||||
* @param {object} config - Configuration object with lat/lon properties
|
||||
* @param {number} maxDecimals - Maximum decimal places to preserve
|
||||
* @throws {Error} If coordinates are missing or invalid
|
||||
*/
|
||||
function validateCoordinates (config, maxDecimals = 4) {
|
||||
if (config.lat == null || config.lon == null
|
||||
|| !Number.isFinite(config.lat) || !Number.isFinite(config.lon)) {
|
||||
throw new Error("Latitude and longitude are required");
|
||||
}
|
||||
|
||||
config.lat = limitDecimals(config.lat, maxDecimals);
|
||||
config.lon = limitDecimals(config.lon, maxDecimals);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
convertWeatherType,
|
||||
applyTimezoneOffset,
|
||||
limitDecimals,
|
||||
getSunTimes,
|
||||
isDayTime,
|
||||
formatTimezoneOffset,
|
||||
getDateString,
|
||||
convertKmhToMs,
|
||||
cardinalToDegrees,
|
||||
validateCoordinates
|
||||
};
|
||||
@@ -1,450 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const { convertKmhToMs } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Environment Canada MSC Datamart
|
||||
* Canada only, no API key required (anonymous access)
|
||||
*
|
||||
* Documentation:
|
||||
* https://dd.weather.gc.ca/citypage_weather/schema/
|
||||
* https://eccc-msc.github.io/open-data/msc-datamart/readme_en/
|
||||
*
|
||||
* Requires siteCode and provCode config parameters
|
||||
* See https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv
|
||||
*/
|
||||
class EnvCanadaProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
siteCode: "s0000000",
|
||||
provCode: "ON",
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
this.lastCityPageURL = null;
|
||||
this.cacheCurrentTemp = null;
|
||||
this.currentHour = null; // Track current hour for URL updates
|
||||
}
|
||||
|
||||
initialize () {
|
||||
this.#validateConfig();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#validateConfig () {
|
||||
if (!this.config.siteCode || !this.config.provCode) {
|
||||
throw new Error("siteCode and provCode are required");
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
this.currentHour = new Date().toISOString().substring(11, 13);
|
||||
const indexURL = this.#getIndexUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(indexURL, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
logContext: "weatherprovider.envcanada"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
// Check if hour changed - restart fetcher with new URL
|
||||
const newHour = new Date().toISOString().substring(11, 13);
|
||||
if (newHour !== this.currentHour) {
|
||||
Log.info("[envcanada] Hour changed, reinitializing fetcher");
|
||||
this.stop();
|
||||
this.#initializeFetcher();
|
||||
this.start();
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const cityPageURL = this.#extractCityPageURL(html);
|
||||
|
||||
if (!cityPageURL) {
|
||||
// This can happen during hour transitions when old responses arrive
|
||||
Log.debug("[envcanada] Could not find city page URL (may be stale response from previous hour)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cityPageURL === this.lastCityPageURL) {
|
||||
Log.debug("[envcanada] City page unchanged");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastCityPageURL = cityPageURL;
|
||||
await this.#fetchCityPage(cityPageURL);
|
||||
|
||||
} catch (error) {
|
||||
Log.error("[envcanada] Error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #fetchCityPage (url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const xml = await response.text();
|
||||
const weatherData = this.#parseWeatherData(xml);
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[envcanada] Fetch city page error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to fetch city data",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#parseWeatherData (xml) {
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
return this.#generateCurrentWeather(xml);
|
||||
case "forecast":
|
||||
case "daily":
|
||||
return this.#generateForecast(xml);
|
||||
case "hourly":
|
||||
return this.#generateHourly(xml);
|
||||
default:
|
||||
Log.error(`[envcanada] Unknown weather type: ${this.config.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrentWeather (xml) {
|
||||
const current = { date: new Date() };
|
||||
|
||||
// Try to get temperature from currentConditions first
|
||||
const currentTempStr = this.#extract(xml, /<currentConditions>.*?<temperature[^>]*>(.*?)<\/temperature>/s);
|
||||
|
||||
if (currentTempStr && currentTempStr !== "") {
|
||||
current.temperature = parseFloat(currentTempStr);
|
||||
this.cacheCurrentTemp = current.temperature;
|
||||
} else {
|
||||
// Fallback: extract from first forecast period if currentConditions is empty
|
||||
const firstForecast = xml.match(/<forecast>(.*?)<\/forecast>/s);
|
||||
if (firstForecast) {
|
||||
const forecastXml = firstForecast[1];
|
||||
const temp = this.#extract(forecastXml, /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp && temp !== "") {
|
||||
current.temperature = parseFloat(temp);
|
||||
this.cacheCurrentTemp = current.temperature;
|
||||
} else if (this.cacheCurrentTemp !== null) {
|
||||
current.temperature = this.cacheCurrentTemp;
|
||||
} else {
|
||||
current.temperature = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wind chill / humidex for feels like temperature
|
||||
const windChill = this.#extract(xml, /<windChill[^>]*>(.*?)<\/windChill>/);
|
||||
const humidex = this.#extract(xml, /<humidex[^>]*>(.*?)<\/humidex>/);
|
||||
if (windChill) {
|
||||
current.feelsLikeTemp = parseFloat(windChill);
|
||||
} else if (humidex) {
|
||||
current.feelsLikeTemp = parseFloat(humidex);
|
||||
}
|
||||
|
||||
// Get wind and icon from currentConditions or first forecast
|
||||
const firstForecast = xml.match(/<forecast>(.*?)<\/forecast>/s);
|
||||
if (!firstForecast) {
|
||||
Log.warn("[envcanada] No forecast data available");
|
||||
return current;
|
||||
}
|
||||
|
||||
const forecastXml = firstForecast[1];
|
||||
|
||||
// Wind speed - try currentConditions first, fallback to forecast
|
||||
let windSpeed = this.#extract(xml, /<currentConditions>.*?<wind>.*?<speed[^>]*>(.*?)<\/speed>/s);
|
||||
if (!windSpeed) {
|
||||
windSpeed = this.#extract(forecastXml, /<speed[^>]*>(.*?)<\/speed>/);
|
||||
}
|
||||
if (windSpeed) {
|
||||
current.windSpeed = (windSpeed === "calm") ? 0 : convertKmhToMs(parseFloat(windSpeed));
|
||||
}
|
||||
|
||||
// Wind bearing - try currentConditions first, fallback to forecast
|
||||
let windBearing = this.#extract(xml, /<currentConditions>.*?<wind>.*?<bearing[^>]*>(.*?)<\/bearing>/s);
|
||||
if (!windBearing) {
|
||||
windBearing = this.#extract(forecastXml, /<bearing[^>]*>(.*?)<\/bearing>/);
|
||||
}
|
||||
if (windBearing) current.windFromDirection = parseFloat(windBearing);
|
||||
|
||||
// Try icon from currentConditions first, fallback to forecast
|
||||
let iconCode = this.#extract(xml, /<currentConditions>.*?<iconCode[^>]*>(.*?)<\/iconCode>/s);
|
||||
if (!iconCode) {
|
||||
iconCode = this.#extract(forecastXml, /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
}
|
||||
if (iconCode) current.weatherType = this.#convertWeatherType(iconCode);
|
||||
|
||||
// Humidity from currentConditions
|
||||
const humidity = this.#extract(xml, /<currentConditions>.*?<relativeHumidity[^>]*>(.*?)<\/relativeHumidity>/s);
|
||||
if (humidity) current.humidity = parseFloat(humidity);
|
||||
|
||||
// Precipitation probability from forecast
|
||||
const pop = this.#extract(forecastXml, /<pop[^>]*>(.*?)<\/pop>/);
|
||||
if (pop && pop !== "") {
|
||||
current.precipitationProbability = parseFloat(pop);
|
||||
}
|
||||
|
||||
// Sunrise/sunset (from riseSet, independent of currentConditions)
|
||||
const sunriseTime = this.#extract(xml, /<dateTime[^>]*name="sunrise"[^>]*>.*?<timeStamp>(.*?)<\/timeStamp>/s);
|
||||
const sunsetTime = this.#extract(xml, /<dateTime[^>]*name="sunset"[^>]*>.*?<timeStamp>(.*?)<\/timeStamp>/s);
|
||||
if (sunriseTime) current.sunrise = this.#parseECTime(sunriseTime);
|
||||
if (sunsetTime) current.sunset = this.#parseECTime(sunsetTime);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateForecast (xml) {
|
||||
const days = [];
|
||||
const forecasts = xml.match(/<forecast>(.*?)<\/forecast>/gs) || [];
|
||||
|
||||
if (forecasts.length === 0) return days;
|
||||
|
||||
// Get current temp
|
||||
const currentTempStr = this.#extract(xml, /<currentConditions>.*?<temperature[^>]*>(.*?)<\/temperature>/s);
|
||||
const currentTemp = currentTempStr ? parseFloat(currentTempStr) : null;
|
||||
|
||||
// Check if first forecast is Today or Tonight
|
||||
const isToday = forecasts[0].includes("textForecastName=\"Today\"");
|
||||
|
||||
let nextDay = isToday ? 2 : 1;
|
||||
const lastDay = isToday ? 12 : 11;
|
||||
|
||||
// Process first day
|
||||
const firstDay = {
|
||||
date: new Date(),
|
||||
precipitationProbability: null
|
||||
};
|
||||
this.#extractForecastTemps(firstDay, forecasts, 0, isToday, currentTemp);
|
||||
this.#extractForecastPrecip(firstDay, forecasts, 0);
|
||||
const firstIcon = this.#extract(forecasts[0], /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
if (firstIcon) firstDay.weatherType = this.#convertWeatherType(firstIcon);
|
||||
days.push(firstDay);
|
||||
|
||||
// Process remaining days
|
||||
let date = new Date();
|
||||
for (let i = nextDay; i < lastDay && i < forecasts.length; i += 2) {
|
||||
date = new Date(date);
|
||||
date.setDate(date.getDate() + 1);
|
||||
|
||||
const day = {
|
||||
date: new Date(date),
|
||||
precipitationProbability: null
|
||||
};
|
||||
this.#extractForecastTemps(day, forecasts, i, true, currentTemp);
|
||||
this.#extractForecastPrecip(day, forecasts, i);
|
||||
const icon = this.#extract(forecasts[i], /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
if (icon) day.weatherType = this.#convertWeatherType(icon);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#extractForecastTemps (weather, forecasts, index, hasToday, currentTemp) {
|
||||
let tempToday = null;
|
||||
let tempTonight = null;
|
||||
|
||||
if (hasToday && forecasts[index]) {
|
||||
const temp = this.#extract(forecasts[index], /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp) tempToday = parseFloat(temp);
|
||||
}
|
||||
|
||||
if (forecasts[index + 1]) {
|
||||
const temp = this.#extract(forecasts[index + 1], /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp) tempTonight = parseFloat(temp);
|
||||
}
|
||||
|
||||
if (tempToday !== null && tempTonight !== null) {
|
||||
weather.maxTemperature = Math.max(tempToday, tempTonight);
|
||||
weather.minTemperature = Math.min(tempToday, tempTonight);
|
||||
} else if (tempToday !== null) {
|
||||
weather.maxTemperature = tempToday;
|
||||
weather.minTemperature = currentTemp || tempToday;
|
||||
} else if (tempTonight !== null) {
|
||||
weather.maxTemperature = currentTemp || tempTonight;
|
||||
weather.minTemperature = tempTonight;
|
||||
}
|
||||
}
|
||||
|
||||
#extractForecastPrecip (weather, forecasts, index) {
|
||||
const precips = [];
|
||||
|
||||
if (forecasts[index]) {
|
||||
const pop = this.#extract(forecasts[index], /<pop[^>]*>(.*?)<\/pop>/);
|
||||
if (pop) precips.push(parseFloat(pop));
|
||||
}
|
||||
|
||||
if (forecasts[index + 1]) {
|
||||
const pop = this.#extract(forecasts[index + 1], /<pop[^>]*>(.*?)<\/pop>/);
|
||||
if (pop) precips.push(parseFloat(pop));
|
||||
}
|
||||
|
||||
if (precips.length > 0) {
|
||||
weather.precipitationProbability = Math.max(...precips);
|
||||
}
|
||||
}
|
||||
|
||||
#generateHourly (xml) {
|
||||
const hours = [];
|
||||
const hourlyMatches = xml.matchAll(/<hourlyForecast[^>]*dateTimeUTC="([^"]*)"[^>]*>(.*?)<\/hourlyForecast>/gs);
|
||||
|
||||
for (const [, dateTimeUTC, hourXML] of hourlyMatches) {
|
||||
const weather = {};
|
||||
|
||||
weather.date = this.#parseECTime(dateTimeUTC);
|
||||
|
||||
const temp = this.#extract(hourXML, /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp) weather.temperature = parseFloat(temp);
|
||||
|
||||
const lop = this.#extract(hourXML, /<lop[^>]*>(.*?)<\/lop>/);
|
||||
if (lop) weather.precipitationProbability = parseFloat(lop);
|
||||
|
||||
const icon = this.#extract(hourXML, /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
if (icon) weather.weatherType = this.#convertWeatherType(icon);
|
||||
|
||||
hours.push(weather);
|
||||
if (hours.length >= 24) break;
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#extract (text, pattern) {
|
||||
const match = text.match(pattern);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
#getIndexUrl () {
|
||||
const hour = new Date().toISOString().substring(11, 13);
|
||||
return `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}/${hour}/`;
|
||||
}
|
||||
|
||||
#extractCityPageURL (html) {
|
||||
// New format: {timestamp}_MSC_CitypageWeather_{siteCode}_en.xml
|
||||
const pattern = `[^"]*_MSC_CitypageWeather_${this.config.siteCode}_en\\.xml`;
|
||||
const match = html.match(new RegExp(`href="(${pattern})"`));
|
||||
|
||||
if (match && match[1]) {
|
||||
return this.#getIndexUrl() + match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#parseECTime (timeStr) {
|
||||
if (!timeStr || timeStr.length < 12) return new Date();
|
||||
|
||||
const y = parseInt(timeStr.substring(0, 4), 10);
|
||||
const m = parseInt(timeStr.substring(4, 6), 10) - 1;
|
||||
const d = parseInt(timeStr.substring(6, 8), 10);
|
||||
const h = parseInt(timeStr.substring(8, 10), 10);
|
||||
const min = parseInt(timeStr.substring(10, 12), 10);
|
||||
const s = timeStr.length >= 14 ? parseInt(timeStr.substring(12, 14), 10) : 0;
|
||||
|
||||
// Create UTC date since input timestamps are in UTC
|
||||
return new Date(Date.UTC(y, m, d, h, min, s));
|
||||
}
|
||||
|
||||
#convertWeatherType (iconCode) {
|
||||
const code = parseInt(iconCode, 10);
|
||||
const map = {
|
||||
0: "day-sunny",
|
||||
1: "day-sunny",
|
||||
2: "day-sunny-overcast",
|
||||
3: "day-cloudy",
|
||||
4: "day-cloudy",
|
||||
5: "day-cloudy",
|
||||
6: "day-sprinkle",
|
||||
7: "day-showers",
|
||||
8: "snow",
|
||||
9: "day-thunderstorm",
|
||||
10: "cloud",
|
||||
11: "showers",
|
||||
12: "rain",
|
||||
13: "rain",
|
||||
14: "sleet",
|
||||
15: "sleet",
|
||||
16: "snow",
|
||||
17: "snow",
|
||||
18: "snow",
|
||||
19: "thunderstorm",
|
||||
20: "cloudy",
|
||||
21: "cloudy",
|
||||
22: "day-cloudy",
|
||||
23: "day-haze",
|
||||
24: "fog",
|
||||
25: "snow-wind",
|
||||
26: "sleet",
|
||||
27: "sleet",
|
||||
28: "rain",
|
||||
29: "na",
|
||||
30: "night-clear",
|
||||
31: "night-clear",
|
||||
32: "night-partly-cloudy",
|
||||
33: "night-alt-cloudy",
|
||||
34: "night-alt-cloudy",
|
||||
35: "night-partly-cloudy",
|
||||
36: "night-alt-showers",
|
||||
37: "night-rain-mix",
|
||||
38: "night-alt-snow",
|
||||
39: "night-thunderstorm",
|
||||
40: "snow-wind",
|
||||
41: "tornado",
|
||||
42: "tornado",
|
||||
43: "windy",
|
||||
44: "smoke",
|
||||
45: "sandstorm",
|
||||
46: "thunderstorm",
|
||||
47: "thunderstorm",
|
||||
48: "tornado"
|
||||
};
|
||||
return map[code] || null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EnvCanadaProvider;
|
||||
@@ -1,560 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const { getDateString } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
// https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api
|
||||
const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client";
|
||||
const OPEN_METEO_BASE = "https://api.open-meteo.com/v1";
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Open-Meteo
|
||||
* see https://open-meteo.com/
|
||||
*/
|
||||
class OpenMeteoProvider {
|
||||
// https://open-meteo.com/en/docs
|
||||
hourlyParams = [
|
||||
"temperature_2m",
|
||||
"relativehumidity_2m",
|
||||
"dewpoint_2m",
|
||||
"apparent_temperature",
|
||||
"pressure_msl",
|
||||
"surface_pressure",
|
||||
"cloudcover",
|
||||
"cloudcover_low",
|
||||
"cloudcover_mid",
|
||||
"cloudcover_high",
|
||||
"windspeed_10m",
|
||||
"windspeed_80m",
|
||||
"windspeed_120m",
|
||||
"windspeed_180m",
|
||||
"winddirection_10m",
|
||||
"winddirection_80m",
|
||||
"winddirection_120m",
|
||||
"winddirection_180m",
|
||||
"windgusts_10m",
|
||||
"shortwave_radiation",
|
||||
"direct_radiation",
|
||||
"direct_normal_irradiance",
|
||||
"diffuse_radiation",
|
||||
"vapor_pressure_deficit",
|
||||
"cape",
|
||||
"evapotranspiration",
|
||||
"et0_fao_evapotranspiration",
|
||||
"precipitation",
|
||||
"snowfall",
|
||||
"precipitation_probability",
|
||||
"rain",
|
||||
"showers",
|
||||
"weathercode",
|
||||
"snow_depth",
|
||||
"freezinglevel_height",
|
||||
"visibility",
|
||||
"soil_temperature_0cm",
|
||||
"soil_temperature_6cm",
|
||||
"soil_temperature_18cm",
|
||||
"soil_temperature_54cm",
|
||||
"soil_moisture_0_1cm",
|
||||
"soil_moisture_1_3cm",
|
||||
"soil_moisture_3_9cm",
|
||||
"soil_moisture_9_27cm",
|
||||
"soil_moisture_27_81cm",
|
||||
"uv_index",
|
||||
"uv_index_clear_sky",
|
||||
"is_day",
|
||||
"terrestrial_radiation",
|
||||
"terrestrial_radiation_instant",
|
||||
"shortwave_radiation_instant",
|
||||
"diffuse_radiation_instant",
|
||||
"direct_radiation_instant",
|
||||
"direct_normal_irradiance_instant"
|
||||
];
|
||||
|
||||
dailyParams = [
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"apparent_temperature_min",
|
||||
"apparent_temperature_max",
|
||||
"precipitation_sum",
|
||||
"rain_sum",
|
||||
"showers_sum",
|
||||
"snowfall_sum",
|
||||
"precipitation_hours",
|
||||
"weathercode",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"windspeed_10m_max",
|
||||
"windgusts_10m_max",
|
||||
"winddirection_10m_dominant",
|
||||
"shortwave_radiation_sum",
|
||||
"uv_index_max",
|
||||
"et0_fao_evapotranspiration"
|
||||
];
|
||||
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: OPEN_METEO_BASE,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
pastDays: 0,
|
||||
type: "current",
|
||||
maxNumberOfDays: 5,
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.locationName = null;
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
async initialize () {
|
||||
await this.#fetchLocation();
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callbacks for data/error events
|
||||
* @param {(data: object) => void} onData - Called with weather data
|
||||
* @param {(error: object) => void} onError - Called with error info
|
||||
*/
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic fetching
|
||||
*/
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic fetching
|
||||
*/
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchLocation () {
|
||||
const url = `${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang || "en"}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data && data.city) {
|
||||
this.locationName = `${data.city}, ${data.principalSubdivisionCode}`;
|
||||
}
|
||||
} catch (error) {
|
||||
Log.debug("[openmeteo] Could not load location data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
logContext: "weatherprovider.openmeteo"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[openmeteo] 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
const parsedData = this.#parseWeatherApiResponse(data);
|
||||
|
||||
if (!parsedData) {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Invalid API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let weatherData;
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateWeatherDayFromCurrentWeather(parsedData);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateWeatherObjectsFromForecast(parsedData);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateWeatherObjectsFromHourly(parsedData);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[openmeteo] Unknown type: ${this.config.type}`);
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[openmeteo] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getQueryParameters () {
|
||||
let maxNumberOfDays = this.config.maxNumberOfDays;
|
||||
|
||||
if (this.config.maxNumberOfDays !== undefined && !isNaN(parseFloat(this.config.maxNumberOfDays))) {
|
||||
const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0;
|
||||
const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0;
|
||||
const maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit));
|
||||
maxNumberOfDays = Math.ceil(maxEntries / Math.max(1, daysFactor));
|
||||
}
|
||||
|
||||
const params = {
|
||||
latitude: this.config.lat,
|
||||
longitude: this.config.lon,
|
||||
timeformat: "unixtime",
|
||||
timezone: "auto",
|
||||
past_days: this.config.pastDays ?? 0,
|
||||
daily: this.dailyParams,
|
||||
hourly: this.hourlyParams,
|
||||
temperature_unit: "celsius",
|
||||
windspeed_unit: "ms",
|
||||
precipitation_unit: "mm"
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + Math.max(0, Math.min(7, maxNumberOfDays)));
|
||||
|
||||
params.start_date = getDateString(startDate);
|
||||
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
case "daily":
|
||||
case "forecast":
|
||||
params.end_date = getDateString(endDate);
|
||||
break;
|
||||
case "current":
|
||||
params.current_weather = true;
|
||||
params.end_date = params.start_date;
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
return Object.keys(params)
|
||||
.filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== "")
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
case "hourly":
|
||||
case "daily":
|
||||
return `${encodeURIComponent(key)}=${params[key].join(",")}`;
|
||||
default:
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`;
|
||||
}
|
||||
})
|
||||
.join("&");
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
return `${this.config.apiBase}/forecast?${this.#getQueryParameters()}`;
|
||||
}
|
||||
|
||||
#transposeDataMatrix (data) {
|
||||
return data.time.map((_, index) => Object.keys(data).reduce((row, key) => {
|
||||
const value = data[key][index];
|
||||
return {
|
||||
...row,
|
||||
// Convert Unix timestamps to Date objects
|
||||
// timezone: "auto" returns times already in location timezone
|
||||
[key]: ["time", "sunrise", "sunset"].includes(key) ? new Date(value * 1000) : value
|
||||
};
|
||||
}, {}));
|
||||
}
|
||||
|
||||
#parseWeatherApiResponse (data) {
|
||||
const validByType = {
|
||||
current: data.current_weather && data.current_weather.time,
|
||||
hourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0,
|
||||
daily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0
|
||||
};
|
||||
|
||||
const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type;
|
||||
|
||||
if (!validByType[type]) return null;
|
||||
|
||||
if (type === "current" && !validByType.daily && !validByType.hourly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of ["hourly", "daily"]) {
|
||||
if (typeof data[key] === "object") {
|
||||
data[key] = this.#transposeDataMatrix(data[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.current_weather) {
|
||||
data.current_weather.time = new Date(data.current_weather.time * 1000);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
#convertWeatherType (weathercode, isDayTime) {
|
||||
const weatherConditions = {
|
||||
0: "clear",
|
||||
1: "mainly-clear",
|
||||
2: "partly-cloudy",
|
||||
3: "overcast",
|
||||
45: "fog",
|
||||
48: "depositing-rime-fog",
|
||||
51: "drizzle-light-intensity",
|
||||
53: "drizzle-moderate-intensity",
|
||||
55: "drizzle-dense-intensity",
|
||||
56: "freezing-drizzle-light-intensity",
|
||||
57: "freezing-drizzle-dense-intensity",
|
||||
61: "rain-slight-intensity",
|
||||
63: "rain-moderate-intensity",
|
||||
65: "rain-heavy-intensity",
|
||||
66: "freezing-rain-light-intensity",
|
||||
67: "freezing-rain-heavy-intensity",
|
||||
71: "snow-fall-slight-intensity",
|
||||
73: "snow-fall-moderate-intensity",
|
||||
75: "snow-fall-heavy-intensity",
|
||||
77: "snow-grains",
|
||||
80: "rain-showers-slight",
|
||||
81: "rain-showers-moderate",
|
||||
82: "rain-showers-violent",
|
||||
85: "snow-showers-slight",
|
||||
86: "snow-showers-heavy",
|
||||
95: "thunderstorm",
|
||||
96: "thunderstorm-slight-hail",
|
||||
99: "thunderstorm-heavy-hail"
|
||||
};
|
||||
|
||||
if (!(weathercode in weatherConditions)) return null;
|
||||
|
||||
const mappings = {
|
||||
clear: isDayTime ? "day-sunny" : "night-clear",
|
||||
"mainly-clear": isDayTime ? "day-cloudy" : "night-alt-cloudy",
|
||||
"partly-cloudy": isDayTime ? "day-cloudy" : "night-alt-cloudy",
|
||||
overcast: isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy",
|
||||
fog: isDayTime ? "day-fog" : "night-fog",
|
||||
"depositing-rime-fog": isDayTime ? "day-fog" : "night-fog",
|
||||
"drizzle-light-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
|
||||
"rain-slight-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
|
||||
"rain-showers-slight": isDayTime ? "day-sprinkle" : "night-sprinkle",
|
||||
"drizzle-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
|
||||
"rain-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
|
||||
"rain-showers-moderate": isDayTime ? "day-showers" : "night-showers",
|
||||
"drizzle-dense-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"rain-heavy-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"rain-showers-violent": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"freezing-rain-light-intensity": isDayTime ? "day-rain-mix" : "night-rain-mix",
|
||||
"freezing-drizzle-light-intensity": "snowflake-cold",
|
||||
"freezing-drizzle-dense-intensity": "snowflake-cold",
|
||||
"snow-grains": isDayTime ? "day-sleet" : "night-sleet",
|
||||
"snow-fall-slight-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
|
||||
"snow-fall-moderate-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
|
||||
"snow-fall-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
||||
"freezing-rain-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
||||
"snow-showers-slight": isDayTime ? "day-rain-mix" : "night-rain-mix",
|
||||
"snow-showers-heavy": isDayTime ? "day-rain-mix" : "night-rain-mix",
|
||||
thunderstorm: isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"thunderstorm-slight-hail": isDayTime ? "day-sleet" : "night-sleet",
|
||||
"thunderstorm-heavy-hail": isDayTime ? "day-sleet-storm" : "night-sleet-storm"
|
||||
};
|
||||
|
||||
return mappings[weatherConditions[`${weathercode}`]] || "na";
|
||||
}
|
||||
|
||||
#isDayTime (date, sunrise, sunset) {
|
||||
const time = date.getTime();
|
||||
return time >= sunrise.getTime() && time < sunset.getTime();
|
||||
}
|
||||
|
||||
#generateWeatherDayFromCurrentWeather (parsedData) {
|
||||
// Basic current weather data
|
||||
const current = {
|
||||
date: parsedData.current_weather.time,
|
||||
windSpeed: parsedData.current_weather.windspeed,
|
||||
windFromDirection: parsedData.current_weather.winddirection,
|
||||
temperature: parsedData.current_weather.temperature,
|
||||
weatherType: this.#convertWeatherType(parsedData.current_weather.weathercode, true)
|
||||
};
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
|
||||
// Add daily data if available (after transpose, daily is array of objects)
|
||||
if (parsedData.daily && Array.isArray(parsedData.daily) && parsedData.daily[0]) {
|
||||
const today = parsedData.daily[0];
|
||||
if (today.sunrise) {
|
||||
current.sunrise = today.sunrise;
|
||||
}
|
||||
if (today.sunset) {
|
||||
current.sunset = today.sunset;
|
||||
// Update weatherType with correct day/night status
|
||||
if (current.sunrise && current.sunset) {
|
||||
current.weatherType = this.#convertWeatherType(
|
||||
parsedData.current_weather.weathercode,
|
||||
this.#isDayTime(parsedData.current_weather.time, current.sunrise, current.sunset)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (today.temperature_2m_min !== undefined) {
|
||||
current.minTemperature = today.temperature_2m_min;
|
||||
}
|
||||
if (today.temperature_2m_max !== undefined) {
|
||||
current.maxTemperature = today.temperature_2m_max;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromForecast (parsedData) {
|
||||
return parsedData.daily.map((weather) => ({
|
||||
date: weather.time,
|
||||
windSpeed: weather.windspeed_10m_max,
|
||||
windFromDirection: weather.winddirection_10m_dominant,
|
||||
sunrise: weather.sunrise,
|
||||
sunset: weather.sunset,
|
||||
temperature: parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2),
|
||||
minTemperature: parseFloat(weather.temperature_2m_min),
|
||||
maxTemperature: parseFloat(weather.temperature_2m_max),
|
||||
weatherType: this.#convertWeatherType(weather.weathercode, true),
|
||||
rain: weather.rain_sum != null ? parseFloat(weather.rain_sum) : null,
|
||||
snow: weather.snowfall_sum != null ? parseFloat(weather.snowfall_sum * 10) : null,
|
||||
precipitationAmount: weather.precipitation_sum != null ? parseFloat(weather.precipitation_sum) : null,
|
||||
precipitationProbability: weather.precipitation_hours != null ? parseFloat(weather.precipitation_hours * 100 / 24) : null,
|
||||
uvIndex: weather.uv_index_max != null ? parseFloat(weather.uv_index_max) : null
|
||||
}));
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromHourly (parsedData) {
|
||||
const hours = [];
|
||||
const now = new Date();
|
||||
|
||||
parsedData.hourly.forEach((weather, i) => {
|
||||
// Skip past entries
|
||||
if (weather.time <= now) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate daily index with bounds check
|
||||
const h = Math.ceil((i + 1) / 24) - 1;
|
||||
const safeH = Math.max(0, Math.min(h, parsedData.daily.length - 1));
|
||||
const dailyData = parsedData.daily[safeH];
|
||||
|
||||
const hourlyWeather = {
|
||||
date: weather.time,
|
||||
windSpeed: weather.windspeed_10m,
|
||||
windFromDirection: weather.winddirection_10m,
|
||||
sunrise: dailyData.sunrise,
|
||||
sunset: dailyData.sunset,
|
||||
temperature: parseFloat(weather.temperature_2m),
|
||||
minTemperature: parseFloat(dailyData.temperature_2m_min),
|
||||
maxTemperature: parseFloat(dailyData.temperature_2m_max),
|
||||
weatherType: this.#convertWeatherType(
|
||||
weather.weathercode,
|
||||
this.#isDayTime(weather.time, dailyData.sunrise, dailyData.sunset)
|
||||
),
|
||||
humidity: weather.relativehumidity_2m != null ? parseFloat(weather.relativehumidity_2m) : null,
|
||||
rain: weather.rain != null ? parseFloat(weather.rain) : null,
|
||||
snow: weather.snowfall != null ? parseFloat(weather.snowfall * 10) : null,
|
||||
precipitationAmount: weather.precipitation != null ? parseFloat(weather.precipitation) : null,
|
||||
precipitationProbability: weather.precipitation_probability != null ? parseFloat(weather.precipitation_probability) : null,
|
||||
uvIndex: weather.uv_index != null ? parseFloat(weather.uv_index) : null
|
||||
};
|
||||
|
||||
hours.push(hourlyWeather);
|
||||
});
|
||||
|
||||
return hours;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenMeteoProvider;
|
||||
@@ -1,276 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const weatherUtils = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for OpenWeatherMap
|
||||
* see https://openweathermap.org/
|
||||
*/
|
||||
class OpenWeatherMapProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiVersion: "3.0",
|
||||
apiBase: "https://api.openweathermap.org/data/",
|
||||
weatherEndpoint: "/onecall",
|
||||
locationID: false,
|
||||
location: false,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
apiKey: "",
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
this.locationName = null;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
// Validate callbacks exist
|
||||
if (typeof this.onErrorCallback !== "function") {
|
||||
throw new Error("setCallbacks() must be called before initialize()");
|
||||
}
|
||||
|
||||
if (!this.config.apiKey) {
|
||||
Log.error("[openweathermap] API key is required");
|
||||
this.onErrorCallback({
|
||||
message: "API key is required",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
logContext: "weatherprovider.openweathermap"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[openweathermap] 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#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 (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[openweathermap] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromOnecall (data) {
|
||||
let precip;
|
||||
|
||||
// Get current weather
|
||||
const current = {};
|
||||
if (data.hasOwnProperty("current")) {
|
||||
const timezoneOffset = data.timezone_offset / 60;
|
||||
current.date = weatherUtils.applyTimezoneOffset(new Date(data.current.dt * 1000), timezoneOffset);
|
||||
current.windSpeed = data.current.wind_speed;
|
||||
current.windFromDirection = data.current.wind_deg;
|
||||
current.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.current.sunrise * 1000), timezoneOffset);
|
||||
current.sunset = weatherUtils.applyTimezoneOffset(new Date(data.current.sunset * 1000), timezoneOffset);
|
||||
current.temperature = data.current.temp;
|
||||
current.weatherType = weatherUtils.convertWeatherType(data.current.weather[0].icon);
|
||||
current.humidity = data.current.humidity;
|
||||
current.uvIndex = data.current.uvi;
|
||||
|
||||
precip = false;
|
||||
if (data.current.hasOwnProperty("rain") && !isNaN(data.current.rain["1h"])) {
|
||||
current.rain = data.current.rain["1h"];
|
||||
precip = true;
|
||||
}
|
||||
if (data.current.hasOwnProperty("snow") && !isNaN(data.current.snow["1h"])) {
|
||||
current.snow = data.current.snow["1h"];
|
||||
precip = true;
|
||||
}
|
||||
if (precip) {
|
||||
current.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0);
|
||||
}
|
||||
current.feelsLikeTemp = data.current.feels_like;
|
||||
}
|
||||
|
||||
// Get hourly weather
|
||||
const hours = [];
|
||||
if (data.hasOwnProperty("hourly")) {
|
||||
const timezoneOffset = data.timezone_offset / 60;
|
||||
for (const hour of data.hourly) {
|
||||
const weather = {};
|
||||
weather.date = weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset);
|
||||
weather.temperature = hour.temp;
|
||||
weather.feelsLikeTemp = hour.feels_like;
|
||||
weather.humidity = hour.humidity;
|
||||
weather.windSpeed = hour.wind_speed;
|
||||
weather.windFromDirection = hour.wind_deg;
|
||||
weather.weatherType = weatherUtils.convertWeatherType(hour.weather[0].icon);
|
||||
weather.precipitationProbability = hour.pop !== undefined ? hour.pop * 100 : undefined;
|
||||
weather.uvIndex = hour.uvi;
|
||||
|
||||
precip = false;
|
||||
if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) {
|
||||
weather.rain = hour.rain["1h"];
|
||||
precip = true;
|
||||
}
|
||||
if (hour.hasOwnProperty("snow") && !isNaN(hour.snow["1h"])) {
|
||||
weather.snow = hour.snow["1h"];
|
||||
precip = true;
|
||||
}
|
||||
if (precip) {
|
||||
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
|
||||
}
|
||||
|
||||
hours.push(weather);
|
||||
}
|
||||
}
|
||||
|
||||
// Get daily weather
|
||||
const days = [];
|
||||
if (data.hasOwnProperty("daily")) {
|
||||
const timezoneOffset = data.timezone_offset / 60;
|
||||
for (const day of data.daily) {
|
||||
const weather = {};
|
||||
weather.date = weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset);
|
||||
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(day.sunrise * 1000), timezoneOffset);
|
||||
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(day.sunset * 1000), timezoneOffset);
|
||||
weather.minTemperature = day.temp.min;
|
||||
weather.maxTemperature = day.temp.max;
|
||||
weather.humidity = day.humidity;
|
||||
weather.windSpeed = day.wind_speed;
|
||||
weather.windFromDirection = day.wind_deg;
|
||||
weather.weatherType = weatherUtils.convertWeatherType(day.weather[0].icon);
|
||||
weather.precipitationProbability = day.pop !== undefined ? day.pop * 100 : undefined;
|
||||
weather.uvIndex = day.uvi;
|
||||
|
||||
precip = false;
|
||||
if (!isNaN(day.rain)) {
|
||||
weather.rain = day.rain;
|
||||
precip = true;
|
||||
}
|
||||
if (!isNaN(day.snow)) {
|
||||
weather.snow = day.snow;
|
||||
precip = true;
|
||||
}
|
||||
if (precip) {
|
||||
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
|
||||
}
|
||||
|
||||
days.push(weather);
|
||||
}
|
||||
}
|
||||
|
||||
return { current, hours, days };
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.#getParams();
|
||||
}
|
||||
|
||||
#getParams () {
|
||||
let params = "?";
|
||||
|
||||
if (this.config.weatherEndpoint === "/onecall") {
|
||||
params += `lat=${this.config.lat}`;
|
||||
params += `&lon=${this.config.lon}`;
|
||||
|
||||
if (this.config.type === "current") {
|
||||
params += "&exclude=minutely,hourly,daily";
|
||||
} else if (this.config.type === "hourly") {
|
||||
params += "&exclude=current,minutely,daily";
|
||||
} else if (this.config.type === "daily" || this.config.type === "forecast") {
|
||||
params += "&exclude=current,minutely,hourly";
|
||||
} else {
|
||||
params += "&exclude=minutely";
|
||||
}
|
||||
} else if (this.config.lat && this.config.lon) {
|
||||
params += `lat=${this.config.lat}&lon=${this.config.lon}`;
|
||||
} else if (this.config.locationID) {
|
||||
params += `id=${this.config.locationID}`;
|
||||
} else if (this.config.location) {
|
||||
params += `q=${this.config.location}`;
|
||||
}
|
||||
|
||||
params += "&units=metric";
|
||||
params += `&lang=${this.config.lang || "en"}`;
|
||||
params += `&APPID=${this.config.apiKey}`;
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenWeatherMapProvider;
|
||||
@@ -1,270 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
class PirateweatherProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://api.pirateweather.net",
|
||||
weatherEndpoint: "/forecast",
|
||||
apiKey: "",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
lang: "en",
|
||||
...config
|
||||
};
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
if (!this.config.apiKey) {
|
||||
Log.error("[pirateweather] No API key configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "API key required",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
Accept: "application/json"
|
||||
},
|
||||
logContext: "weatherprovider.pirateweather"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[pirateweather] Parse error:", 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
if (!data || (!data.currently && !data.daily && !data.hourly)) {
|
||||
Log.error("[pirateweather] No usable data received");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "No usable data in API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(data);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[pirateweather] Unknown weather type: ${this.config.type}`);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: `Unknown weather type: ${this.config.type}`,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
if (!data.currently || typeof data.currently.temperature === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = {
|
||||
date: new Date(),
|
||||
humidity: data.currently.humidity != null ? parseFloat(data.currently.humidity) * 100 : null,
|
||||
temperature: parseFloat(data.currently.temperature),
|
||||
feelsLikeTemp: data.currently.apparentTemperature != null ? parseFloat(data.currently.apparentTemperature) : null,
|
||||
windSpeed: data.currently.windSpeed != null ? parseFloat(data.currently.windSpeed) : null,
|
||||
windFromDirection: data.currently.windBearing || null,
|
||||
weatherType: this.#convertWeatherType(data.currently.icon),
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
// Add sunrise/sunset from daily data if available
|
||||
if (data.daily && data.daily.data && data.daily.data.length > 0) {
|
||||
const today = data.daily.data[0];
|
||||
if (today.sunriseTime) {
|
||||
current.sunrise = new Date(today.sunriseTime * 1000);
|
||||
}
|
||||
if (today.sunsetTime) {
|
||||
current.sunset = new Date(today.sunsetTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
if (!data.daily || !data.daily.data || !data.daily.data.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const days = [];
|
||||
|
||||
for (const forecast of data.daily.data) {
|
||||
const day = {
|
||||
date: new Date(forecast.time * 1000),
|
||||
minTemperature: forecast.temperatureMin != null ? parseFloat(forecast.temperatureMin) : null,
|
||||
maxTemperature: forecast.temperatureMax != null ? parseFloat(forecast.temperatureMax) : null,
|
||||
weatherType: this.#convertWeatherType(forecast.icon),
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0,
|
||||
precipitationProbability: forecast.precipProbability != null ? parseFloat(forecast.precipProbability) * 100 : null
|
||||
};
|
||||
|
||||
// Handle precipitation
|
||||
let precip = 0;
|
||||
if (forecast.hasOwnProperty("precipAccumulation")) {
|
||||
precip = forecast.precipAccumulation * 10; // cm to mm
|
||||
}
|
||||
|
||||
day.precipitationAmount = precip;
|
||||
|
||||
if (forecast.precipType) {
|
||||
if (forecast.precipType === "snow") {
|
||||
day.snow = precip;
|
||||
} else {
|
||||
day.rain = precip;
|
||||
}
|
||||
}
|
||||
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
if (!data.hourly || !data.hourly.data || !data.hourly.data.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hours = [];
|
||||
|
||||
for (const forecast of data.hourly.data) {
|
||||
const hour = {
|
||||
date: new Date(forecast.time * 1000),
|
||||
temperature: forecast.temperature !== undefined ? parseFloat(forecast.temperature) : null,
|
||||
feelsLikeTemp: forecast.apparentTemperature !== undefined ? parseFloat(forecast.apparentTemperature) : null,
|
||||
weatherType: this.#convertWeatherType(forecast.icon),
|
||||
windSpeed: forecast.windSpeed !== undefined ? parseFloat(forecast.windSpeed) : null,
|
||||
windFromDirection: forecast.windBearing || null,
|
||||
precipitationProbability: forecast.precipProbability ? parseFloat(forecast.precipProbability) * 100 : null,
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0
|
||||
};
|
||||
|
||||
// Handle precipitation
|
||||
let precip = 0;
|
||||
if (forecast.hasOwnProperty("precipAccumulation")) {
|
||||
precip = forecast.precipAccumulation * 10; // cm to mm
|
||||
}
|
||||
|
||||
hour.precipitationAmount = precip;
|
||||
|
||||
if (forecast.precipType) {
|
||||
if (forecast.precipType === "snow") {
|
||||
hour.snow = precip;
|
||||
} else {
|
||||
hour.rain = precip;
|
||||
}
|
||||
}
|
||||
|
||||
hours.push(hour);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
const apiBase = this.config.apiBase || "https://api.pirateweather.net";
|
||||
const weatherEndpoint = this.config.weatherEndpoint || "/forecast";
|
||||
const lang = this.config.lang || "en";
|
||||
return `${apiBase}${weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${lang}`;
|
||||
}
|
||||
|
||||
#convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
"clear-day": "day-sunny",
|
||||
"clear-night": "night-clear",
|
||||
rain: "rain",
|
||||
snow: "snow",
|
||||
sleet: "snow",
|
||||
wind: "windy",
|
||||
fog: "fog",
|
||||
cloudy: "cloudy",
|
||||
"partly-cloudy-day": "day-cloudy",
|
||||
"partly-cloudy-night": "night-cloudy"
|
||||
};
|
||||
|
||||
return weatherTypes[weatherType] || null;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PirateweatherProvider;
|
||||
@@ -1,397 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const { getSunTimes, isDayTime, validateCoordinates } = require("../provider-utils");
|
||||
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/
|
||||
*/
|
||||
class SMHIProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
precipitationValue: "pmedian", // pmin, pmean, pmedian, pmax
|
||||
type: "current",
|
||||
updateInterval: 5 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
// Validate precipitationValue
|
||||
if (!["pmin", "pmean", "pmedian", "pmax"].includes(this.config.precipitationValue)) {
|
||||
Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`);
|
||||
this.config.precipitationValue = "pmedian";
|
||||
}
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
try {
|
||||
// SMHI requires max 6 decimal places
|
||||
validateCoordinates(this.config, 6);
|
||||
this.#initializeFetcher();
|
||||
} catch (error) {
|
||||
Log.error("[smhi] Initialization failed:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
logContext: "weatherprovider.smhi"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[smhi] 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
try {
|
||||
if (!data.timeSeries || !Array.isArray(data.timeSeries)) {
|
||||
throw new Error("Invalid weather data");
|
||||
}
|
||||
|
||||
const coordinates = this.#resolveCoordinates(data);
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrentWeather(data.timeSeries, coordinates);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateForecast(data.timeSeries, coordinates);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data.timeSeries, coordinates);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[smhi] Unknown weather type: ${this.config.type}`);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: `Unknown weather type: ${this.config.type}`,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[smhi] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrentWeather (timeSeries, coordinates) {
|
||||
const closest = this.#getClosestToCurrentTime(timeSeries);
|
||||
return this.#convertWeatherDataToObject(closest, coordinates);
|
||||
}
|
||||
|
||||
#generateForecast (timeSeries, coordinates) {
|
||||
const filled = this.#fillInGaps(timeSeries);
|
||||
return this.#convertWeatherDataGroupedBy(filled, coordinates, "day");
|
||||
}
|
||||
|
||||
#generateHourly (timeSeries, coordinates) {
|
||||
const filled = this.#fillInGaps(timeSeries);
|
||||
return this.#convertWeatherDataGroupedBy(filled, coordinates, "hour");
|
||||
}
|
||||
|
||||
#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);
|
||||
|
||||
if (minDiff === null || diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = time;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
#convertWeatherDataToObject (weatherData, coordinates) {
|
||||
const date = new Date(weatherData.validTime);
|
||||
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),
|
||||
feelsLikeTemp: this.#calculateApparentTemperature(weatherData),
|
||||
sunrise: sunrise,
|
||||
sunset: sunset,
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0
|
||||
};
|
||||
|
||||
// Determine precipitation amount and category
|
||||
const precipitationValue = this.#paramValue(weatherData, this.config.precipitationValue);
|
||||
const pcat = this.#paramValue(weatherData, "pcat");
|
||||
|
||||
switch (pcat) {
|
||||
case 1: // Snow
|
||||
current.snow = precipitationValue;
|
||||
current.precipitationAmount = precipitationValue;
|
||||
break;
|
||||
case 2: // Snow and rain (50/50 split)
|
||||
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;
|
||||
current.precipitationAmount = precipitationValue;
|
||||
break;
|
||||
// case 0: No precipitation - defaults already set to 0
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") {
|
||||
const result = [];
|
||||
let currentWeather = null;
|
||||
let dayWeatherTypes = [];
|
||||
|
||||
const allWeatherObjects = allWeatherData.map((data) => this.#convertWeatherDataToObject(data, coordinates));
|
||||
|
||||
for (const weatherObject of allWeatherObjects) {
|
||||
const objDate = new Date(weatherObject.date);
|
||||
|
||||
// Check if we need a new group (day or hour change)
|
||||
const needNewGroup = !currentWeather || !this.#isSamePeriod(currentWeather.date, objDate, groupBy);
|
||||
|
||||
if (needNewGroup) {
|
||||
currentWeather = {
|
||||
date: objDate,
|
||||
temperature: weatherObject.temperature,
|
||||
minTemperature: Infinity,
|
||||
maxTemperature: -Infinity,
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0,
|
||||
sunrise: weatherObject.sunrise,
|
||||
sunset: weatherObject.sunset
|
||||
};
|
||||
dayWeatherTypes = [];
|
||||
result.push(currentWeather);
|
||||
}
|
||||
|
||||
// Track weather types during daytime
|
||||
const { sunrise: daySunrise, sunset: daySunset } = getSunTimes(objDate, coordinates.lat, coordinates.lon);
|
||||
const isDay = isDayTime(objDate, daySunrise, daySunset);
|
||||
|
||||
if (isDay) {
|
||||
dayWeatherTypes.push(weatherObject.weatherType);
|
||||
}
|
||||
|
||||
// Use median weather type from daytime hours
|
||||
if (dayWeatherTypes.length > 0) {
|
||||
currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)];
|
||||
} else {
|
||||
currentWeather.weatherType = weatherObject.weatherType;
|
||||
}
|
||||
|
||||
// Aggregate min/max and precipitation
|
||||
currentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature);
|
||||
currentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature);
|
||||
currentWeather.snow += weatherObject.snow;
|
||||
currentWeather.rain += weatherObject.rain;
|
||||
currentWeather.precipitationAmount += weatherObject.precipitationAmount;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#isSamePeriod (date1, date2, groupBy) {
|
||||
if (groupBy === "hour") {
|
||||
return date1.getFullYear() === date2.getFullYear()
|
||||
&& date1.getMonth() === date2.getMonth()
|
||||
&& date1.getDate() === date2.getDate()
|
||||
&& date1.getHours() === date2.getHours();
|
||||
} else { // day
|
||||
return date1.getFullYear() === date2.getFullYear()
|
||||
&& date1.getMonth() === date2.getMonth()
|
||||
&& date1.getDate() === date2.getDate();
|
||||
}
|
||||
}
|
||||
|
||||
#fillInGaps (data) {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const result = [];
|
||||
result.push(data[0]); // Keep first data point
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const from = new Date(data[i - 1].validTime);
|
||||
const to = new Date(data[i].validTime);
|
||||
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)
|
||||
for (let j = 1; j < hours; j++) {
|
||||
const current = { ...data[i - 1] };
|
||||
const newTime = new Date(from);
|
||||
newTime.setHours(from.getHours() + j);
|
||||
current.validTime = newTime.toISOString();
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
// Push original data point
|
||||
result.push(data[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#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) {
|
||||
return {
|
||||
lat: data.geometry.coordinates[0][1],
|
||||
lon: data.geometry.coordinates[0][0]
|
||||
};
|
||||
}
|
||||
|
||||
Log.warn("[smhi] Invalid coordinate structure in response, using config values");
|
||||
return {
|
||||
lat: this.config.lat,
|
||||
lon: this.config.lon
|
||||
};
|
||||
}
|
||||
|
||||
#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));
|
||||
|
||||
return Ta + 0.33 * p - 0.7 * ws - 4;
|
||||
}
|
||||
|
||||
#paramValue (weatherData, name) {
|
||||
const param = weatherData.parameters.find((p) => p.name === name);
|
||||
return param ? param.values[0] : null;
|
||||
}
|
||||
|
||||
#convertWeatherType (input, isDayTime) {
|
||||
switch (input) {
|
||||
case 1:
|
||||
return isDayTime ? "day-sunny" : "night-clear"; // Clear sky
|
||||
case 2:
|
||||
return isDayTime ? "day-sunny-overcast" : "night-partly-cloudy"; // Nearly clear sky
|
||||
case 3:
|
||||
case 4:
|
||||
return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable/halfclear cloudiness
|
||||
case 5:
|
||||
case 6:
|
||||
return "cloudy"; // Cloudy/overcast
|
||||
case 7:
|
||||
return "fog";
|
||||
case 8:
|
||||
case 9:
|
||||
case 10:
|
||||
return "showers"; // Light/moderate/heavy rain showers
|
||||
case 11:
|
||||
case 21:
|
||||
return "thunderstorm";
|
||||
case 12:
|
||||
case 13:
|
||||
case 14:
|
||||
case 22:
|
||||
case 23:
|
||||
case 24:
|
||||
return "sleet"; // Light/moderate/heavy sleet (showers)
|
||||
case 15:
|
||||
case 16:
|
||||
case 17:
|
||||
case 25:
|
||||
case 26:
|
||||
case 27:
|
||||
return "snow"; // Light/moderate/heavy snow (showers/fall)
|
||||
case 18:
|
||||
case 19:
|
||||
case 20:
|
||||
return "rain"; // Light/moderate/heavy rain
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#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`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SMHIProvider;
|
||||
@@ -1,329 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const { getSunTimes } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* UK Met Office Data Hub provider
|
||||
* For more information: https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub
|
||||
*
|
||||
* Data available:
|
||||
* - Hourly data for next 2 days (for current weather)
|
||||
* - 3-hourly data for next 7 days (for hourly forecasts)
|
||||
* - Daily data for next 7 days (for daily forecasts)
|
||||
*
|
||||
* Free accounts limited to 360 requests/day per service (once every 4 minutes)
|
||||
*/
|
||||
class UkMetOfficeDataHubProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/",
|
||||
apiKey: "",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") {
|
||||
Log.error("[ukmetofficedatahub] No API key configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "UK Met Office DataHub API key required. Get one at https://datahub.metoffice.gov.uk/",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const forecastType = this.#getForecastType();
|
||||
const url = this.#getUrl(forecastType);
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
apikey: this.config.apiKey
|
||||
},
|
||||
logContext: "weatherprovider.ukmetofficedatahub"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[ukmetofficedatahub] Parse error:", 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#getForecastType () {
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
return "three-hourly";
|
||||
case "forecast":
|
||||
case "daily":
|
||||
return "daily";
|
||||
case "current":
|
||||
default:
|
||||
return "hourly";
|
||||
}
|
||||
}
|
||||
|
||||
#getUrl (forecastType) {
|
||||
const base = this.config.apiBase.endsWith("/") ? this.config.apiBase : `${this.config.apiBase}/`;
|
||||
const queryStrings = `?latitude=${this.config.lat}&longitude=${this.config.lon}&includeLocationName=true`;
|
||||
return `${base}${forecastType}${queryStrings}`;
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
if (!data || !data.features || !data.features[0] || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {
|
||||
Log.error("[ukmetofficedatahub] No usable data received");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "No usable data in API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(data);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[ukmetofficedatahub] Unknown weather type: ${this.config.type}`);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: `Unknown weather type: ${this.config.type}`,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
const timeSeries = data.features[0].properties.timeSeries;
|
||||
const now = new Date();
|
||||
|
||||
// Find the hour that contains current time
|
||||
for (const hour of timeSeries) {
|
||||
const forecastTime = new Date(hour.time);
|
||||
const oneHourLater = new Date(forecastTime.getTime() + 60 * 60 * 1000);
|
||||
|
||||
if (now >= forecastTime && now < oneHourLater) {
|
||||
const current = {
|
||||
date: forecastTime,
|
||||
temperature: hour.screenTemperature || null,
|
||||
minTemperature: hour.minScreenAirTemp || null,
|
||||
maxTemperature: hour.maxScreenAirTemp || null,
|
||||
windSpeed: hour.windSpeed10m || null,
|
||||
windFromDirection: hour.windDirectionFrom10m || null,
|
||||
weatherType: this.#convertWeatherType(hour.significantWeatherCode),
|
||||
humidity: hour.screenRelativeHumidity || null,
|
||||
rain: hour.totalPrecipAmount || 0,
|
||||
snow: hour.totalSnowAmount || 0,
|
||||
precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0),
|
||||
precipitationProbability: hour.probOfPrecipitation || null,
|
||||
feelsLikeTemp: hour.feelsLikeTemperature || null,
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
// Calculate sunrise/sunset using SunCalc
|
||||
const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon);
|
||||
current.sunrise = sunrise;
|
||||
current.sunset = sunset;
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first hour if no match found
|
||||
const firstHour = timeSeries[0];
|
||||
const current = {
|
||||
date: new Date(firstHour.time),
|
||||
temperature: firstHour.screenTemperature || null,
|
||||
windSpeed: firstHour.windSpeed10m || null,
|
||||
windFromDirection: firstHour.windDirectionFrom10m || null,
|
||||
weatherType: this.#convertWeatherType(firstHour.significantWeatherCode),
|
||||
humidity: firstHour.screenRelativeHumidity || null,
|
||||
rain: firstHour.totalPrecipAmount || 0,
|
||||
snow: firstHour.totalSnowAmount || 0,
|
||||
precipitationAmount: (firstHour.totalPrecipAmount || 0) + (firstHour.totalSnowAmount || 0),
|
||||
precipitationProbability: firstHour.probOfPrecipitation || null,
|
||||
feelsLikeTemp: firstHour.feelsLikeTemperature || null,
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon);
|
||||
current.sunrise = sunrise;
|
||||
current.sunset = sunset;
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
const timeSeries = data.features[0].properties.timeSeries;
|
||||
const days = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (const day of timeSeries) {
|
||||
const forecastDate = new Date(day.time);
|
||||
forecastDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Only include today and future days
|
||||
if (forecastDate >= today) {
|
||||
days.push({
|
||||
date: new Date(day.time),
|
||||
minTemperature: day.nightMinScreenTemperature || null,
|
||||
maxTemperature: day.dayMaxScreenTemperature || null,
|
||||
temperature: day.dayMaxScreenTemperature || null,
|
||||
windSpeed: day.midday10MWindSpeed || null,
|
||||
windFromDirection: day.midday10MWindDirection || null,
|
||||
weatherType: this.#convertWeatherType(day.daySignificantWeatherCode),
|
||||
humidity: day.middayRelativeHumidity || null,
|
||||
rain: day.dayProbabilityOfRain || 0,
|
||||
snow: day.dayProbabilityOfSnow || 0,
|
||||
precipitationAmount: 0,
|
||||
precipitationProbability: day.dayProbabilityOfPrecipitation || null,
|
||||
feelsLikeTemp: day.dayMaxFeelsLikeTemp || null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
const timeSeries = data.features[0].properties.timeSeries;
|
||||
const hours = [];
|
||||
|
||||
for (const hour of timeSeries) {
|
||||
// 3-hourly data uses maxScreenAirTemp/minScreenAirTemp, not screenTemperature
|
||||
const temp = hour.screenTemperature !== undefined
|
||||
? hour.screenTemperature
|
||||
: (hour.maxScreenAirTemp !== undefined && hour.minScreenAirTemp !== undefined)
|
||||
? (hour.maxScreenAirTemp + hour.minScreenAirTemp) / 2
|
||||
: null;
|
||||
|
||||
hours.push({
|
||||
date: new Date(hour.time),
|
||||
temperature: temp,
|
||||
windSpeed: hour.windSpeed10m || null,
|
||||
windFromDirection: hour.windDirectionFrom10m || null,
|
||||
weatherType: this.#convertWeatherType(hour.significantWeatherCode),
|
||||
humidity: hour.screenRelativeHumidity || null,
|
||||
rain: hour.totalPrecipAmount || 0,
|
||||
snow: hour.totalSnowAmount || 0,
|
||||
precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0),
|
||||
precipitationProbability: hour.probOfPrecipitation || null,
|
||||
feelsLikeTemp: hour.feelsLikeTemp || null
|
||||
});
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Met Office significant weather code to weathericons.css icon
|
||||
* See: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264
|
||||
* @param {number} weatherType - Met Office weather code
|
||||
* @returns {string|null} Weathericons.css icon name or null
|
||||
*/
|
||||
#convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
0: "night-clear",
|
||||
1: "day-sunny",
|
||||
2: "night-alt-cloudy",
|
||||
3: "day-cloudy",
|
||||
5: "fog",
|
||||
6: "fog",
|
||||
7: "cloudy",
|
||||
8: "cloud",
|
||||
9: "night-sprinkle",
|
||||
10: "day-sprinkle",
|
||||
11: "raindrops",
|
||||
12: "sprinkle",
|
||||
13: "night-alt-showers",
|
||||
14: "day-showers",
|
||||
15: "rain",
|
||||
16: "night-alt-sleet",
|
||||
17: "day-sleet",
|
||||
18: "sleet",
|
||||
19: "night-alt-hail",
|
||||
20: "day-hail",
|
||||
21: "hail",
|
||||
22: "night-alt-snow",
|
||||
23: "day-snow",
|
||||
24: "snow",
|
||||
25: "night-alt-snow",
|
||||
26: "day-snow",
|
||||
27: "snow",
|
||||
28: "night-alt-thunderstorm",
|
||||
29: "day-thunderstorm",
|
||||
30: "thunderstorm"
|
||||
};
|
||||
|
||||
return weatherTypes[weatherType] || null;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UkMetOfficeDataHubProvider;
|
||||
@@ -1,490 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const { convertKmhToMs, cardinalToDegrees } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
const WEATHER_API_BASE = "https://api.weatherapi.com/v1";
|
||||
|
||||
class WeatherAPIProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: WEATHER_API_BASE,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
apiKey: "",
|
||||
lang: "en",
|
||||
maxEntries: 5,
|
||||
maxNumberOfDays: 5,
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.locationName = null;
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
this.#validateConfig();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#validateConfig () {
|
||||
this.config.type = `${this.config.type ?? ""}`.trim().toLowerCase();
|
||||
|
||||
if (this.config.type === "forecast") {
|
||||
this.config.type = "daily";
|
||||
}
|
||||
|
||||
if (!["hourly", "daily", "current"].includes(this.config.type)) {
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (!this.config.apiKey || `${this.config.apiKey}`.trim() === "") {
|
||||
throw new Error("apiKey is required");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(this.config.lat) || !Number.isFinite(this.config.lon)) {
|
||||
throw new Error("Latitude and longitude are required");
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
logContext: "weatherprovider.weatherapi"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weatherapi] 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
let parsedData;
|
||||
|
||||
try {
|
||||
parsedData = this.#parseResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weatherapi] Invalid API response:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Invalid API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(parsedData);
|
||||
break;
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(parsedData);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(parsedData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (this.onDataCallback && weatherData) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[weatherapi] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getQueryParameters () {
|
||||
const maxEntries = Number.isFinite(this.config.maxEntries)
|
||||
? Math.max(1, this.config.maxEntries)
|
||||
: 5;
|
||||
|
||||
const requestedDays = Number.isFinite(this.config.maxNumberOfDays)
|
||||
? Math.max(1, this.config.maxNumberOfDays)
|
||||
: 5;
|
||||
|
||||
const hourlyDays = Math.max(1, Math.ceil(maxEntries / 24));
|
||||
const days = this.config.type === "hourly"
|
||||
? Math.min(14, Math.max(requestedDays, hourlyDays))
|
||||
: this.config.type === "daily"
|
||||
? Math.min(14, requestedDays)
|
||||
: 1;
|
||||
|
||||
const params = {
|
||||
q: `${this.config.lat},${this.config.lon}`,
|
||||
days,
|
||||
lang: this.config.lang,
|
||||
key: this.config.apiKey
|
||||
};
|
||||
|
||||
return Object.keys(params)
|
||||
.filter((key) => params[key] !== undefined && params[key] !== null && `${params[key]}`.trim() !== "")
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
return `${this.config.apiBase}/forecast.json?${this.#getQueryParameters()}`;
|
||||
}
|
||||
|
||||
#parseResponse (responseData) {
|
||||
responseData.location ??= {};
|
||||
responseData.current ??= {};
|
||||
responseData.current.condition ??= {};
|
||||
responseData.forecast ??= {};
|
||||
responseData.forecast.forecastday ??= [];
|
||||
responseData.forecast.forecastday = responseData.forecast.forecastday.map((forecastDay) => ({
|
||||
...forecastDay,
|
||||
astro: forecastDay.astro ?? {},
|
||||
day: forecastDay.day ?? {},
|
||||
hour: forecastDay.hour ?? []
|
||||
}));
|
||||
|
||||
const locationParts = [
|
||||
responseData.location.name,
|
||||
responseData.location.region,
|
||||
responseData.location.country
|
||||
]
|
||||
.map((value) => `${value}`.trim())
|
||||
.filter((value) => value !== "");
|
||||
|
||||
if (locationParts.length > 0) {
|
||||
this.locationName = locationParts.join(", ").trim();
|
||||
}
|
||||
|
||||
if (
|
||||
!responseData.location
|
||||
|| !responseData.current
|
||||
|| !responseData.forecast
|
||||
|| !Array.isArray(responseData.forecast.forecastday)
|
||||
) {
|
||||
throw new Error("Invalid API response");
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
#parseSunDatetime (forecastDay, key) {
|
||||
const timeValue = forecastDay?.astro?.[key];
|
||||
if (!timeValue || !forecastDay?.date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = (/^\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*$/i).exec(timeValue);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hour = parseInt(match[1], 10);
|
||||
const minute = parseInt(match[2], 10);
|
||||
const period = match[3].toUpperCase();
|
||||
|
||||
if (period === "PM" && hour !== 12) hour += 12;
|
||||
if (period === "AM" && hour === 12) hour = 0;
|
||||
|
||||
const date = new Date(`${forecastDay.date}T00:00:00`);
|
||||
date.setHours(hour, minute, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
#toNumber (value) {
|
||||
const number = parseFloat(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
const weather = data.forecast.forecastday[0] ?? {};
|
||||
const current = data.current ?? {};
|
||||
const currentWeather = {
|
||||
date: current.last_updated_epoch ? new Date(current.last_updated_epoch * 1000) : new Date()
|
||||
};
|
||||
|
||||
const humidity = this.#toNumber(current.humidity);
|
||||
if (humidity !== null) currentWeather.humidity = humidity;
|
||||
|
||||
const temperature = this.#toNumber(current.temp_c);
|
||||
if (temperature !== null) currentWeather.temperature = temperature;
|
||||
|
||||
const feelsLikeTemp = this.#toNumber(current.feelslike_c);
|
||||
if (feelsLikeTemp !== null) currentWeather.feelsLikeTemp = feelsLikeTemp;
|
||||
|
||||
const windSpeed = this.#toNumber(current.wind_kph);
|
||||
if (windSpeed !== null) currentWeather.windSpeed = convertKmhToMs(windSpeed);
|
||||
|
||||
const windFromDirection = this.#toNumber(current.wind_degree);
|
||||
if (windFromDirection !== null) currentWeather.windFromDirection = windFromDirection;
|
||||
|
||||
if (current.condition?.code !== undefined) {
|
||||
currentWeather.weatherType = this.#convertWeatherType(current.condition.code, current.is_day === 1);
|
||||
}
|
||||
|
||||
const sunrise = this.#parseSunDatetime(weather, "sunrise");
|
||||
const sunset = this.#parseSunDatetime(weather, "sunset");
|
||||
if (sunrise) currentWeather.sunrise = sunrise;
|
||||
if (sunset) currentWeather.sunset = sunset;
|
||||
|
||||
const minTemperature = this.#toNumber(weather.day?.mintemp_c);
|
||||
if (minTemperature !== null) currentWeather.minTemperature = minTemperature;
|
||||
|
||||
const maxTemperature = this.#toNumber(weather.day?.maxtemp_c);
|
||||
if (maxTemperature !== null) currentWeather.maxTemperature = maxTemperature;
|
||||
|
||||
const snow = this.#toNumber(current.snow_cm);
|
||||
if (snow !== null) currentWeather.snow = snow * 10;
|
||||
|
||||
const rain = this.#toNumber(current.precip_mm);
|
||||
if (rain !== null) currentWeather.rain = rain;
|
||||
|
||||
if (rain !== null || snow !== null) {
|
||||
currentWeather.precipitationAmount = (rain ?? 0) + ((snow ?? 0) * 10);
|
||||
}
|
||||
|
||||
return currentWeather;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
const days = [];
|
||||
const forecastDays = data.forecast.forecastday ?? [];
|
||||
|
||||
for (const forecastDay of forecastDays) {
|
||||
const weather = {};
|
||||
const dayDate = forecastDay.date_epoch
|
||||
? new Date(forecastDay.date_epoch * 1000)
|
||||
: new Date(`${forecastDay.date}T00:00:00`);
|
||||
|
||||
const precipitationProbability = forecastDay.hour?.length > 0
|
||||
? (forecastDay.hour.reduce((sum, hourData) => {
|
||||
const rain = this.#toNumber(hourData.will_it_rain) ?? 0;
|
||||
const snow = this.#toNumber(hourData.will_it_snow) ?? 0;
|
||||
return sum + ((rain + snow) / 2);
|
||||
}, 0) / forecastDay.hour.length) * 100
|
||||
: null;
|
||||
|
||||
const avgWindDegree = forecastDay.hour?.length > 0
|
||||
? forecastDay.hour.reduce((sum, hourData) => {
|
||||
return sum + (this.#toNumber(hourData.wind_degree) ?? 0);
|
||||
}, 0) / forecastDay.hour.length
|
||||
: null;
|
||||
|
||||
weather.date = dayDate;
|
||||
weather.minTemperature = this.#toNumber(forecastDay.day?.mintemp_c);
|
||||
weather.maxTemperature = this.#toNumber(forecastDay.day?.maxtemp_c);
|
||||
weather.weatherType = this.#convertWeatherType(forecastDay.day?.condition?.code, true);
|
||||
|
||||
const maxWind = this.#toNumber(forecastDay.day?.maxwind_kph);
|
||||
if (maxWind !== null) weather.windSpeed = convertKmhToMs(maxWind);
|
||||
|
||||
if (avgWindDegree !== null) {
|
||||
weather.windFromDirection = avgWindDegree;
|
||||
}
|
||||
|
||||
const sunrise = this.#parseSunDatetime(forecastDay, "sunrise");
|
||||
const sunset = this.#parseSunDatetime(forecastDay, "sunset");
|
||||
if (sunrise) weather.sunrise = sunrise;
|
||||
if (sunset) weather.sunset = sunset;
|
||||
|
||||
weather.temperature = this.#toNumber(forecastDay.day?.avgtemp_c);
|
||||
weather.humidity = this.#toNumber(forecastDay.day?.avghumidity);
|
||||
|
||||
const snow = this.#toNumber(forecastDay.day?.totalsnow_cm);
|
||||
if (snow !== null) weather.snow = snow * 10;
|
||||
|
||||
const rain = this.#toNumber(forecastDay.day?.totalprecip_mm);
|
||||
if (rain !== null) weather.rain = rain;
|
||||
|
||||
if (rain !== null || snow !== null) {
|
||||
weather.precipitationAmount = (rain ?? 0) + ((snow ?? 0) * 10);
|
||||
}
|
||||
|
||||
if (precipitationProbability !== null) {
|
||||
weather.precipitationProbability = precipitationProbability;
|
||||
}
|
||||
|
||||
weather.uv_index = this.#toNumber(forecastDay.day?.uv);
|
||||
|
||||
days.push(weather);
|
||||
|
||||
if (days.length >= this.config.maxEntries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
const hours = [];
|
||||
const nowStart = new Date();
|
||||
nowStart.setMinutes(0, 0, 0);
|
||||
nowStart.setHours(nowStart.getHours() + 1);
|
||||
|
||||
for (const forecastDay of data.forecast.forecastday ?? []) {
|
||||
for (const hourData of forecastDay.hour ?? []) {
|
||||
const date = hourData.time_epoch
|
||||
? new Date(hourData.time_epoch * 1000)
|
||||
: new Date(hourData.time);
|
||||
|
||||
if (date < nowStart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const weather = { date };
|
||||
|
||||
const sunrise = this.#parseSunDatetime(forecastDay, "sunrise");
|
||||
const sunset = this.#parseSunDatetime(forecastDay, "sunset");
|
||||
if (sunrise) weather.sunrise = sunrise;
|
||||
if (sunset) weather.sunset = sunset;
|
||||
|
||||
weather.minTemperature = this.#toNumber(forecastDay.day?.mintemp_c);
|
||||
weather.maxTemperature = this.#toNumber(forecastDay.day?.maxtemp_c);
|
||||
weather.humidity = this.#toNumber(hourData.humidity);
|
||||
|
||||
const windSpeed = this.#toNumber(hourData.wind_kph);
|
||||
if (windSpeed !== null) weather.windSpeed = convertKmhToMs(windSpeed);
|
||||
|
||||
const windDegree = this.#toNumber(hourData.wind_degree);
|
||||
weather.windFromDirection = windDegree !== null
|
||||
? windDegree
|
||||
: cardinalToDegrees(hourData.wind_dir);
|
||||
|
||||
weather.weatherType = this.#convertWeatherType(hourData.condition?.code, hourData.is_day === 1);
|
||||
|
||||
const snow = this.#toNumber(hourData.snow_cm);
|
||||
if (snow !== null) weather.snow = snow * 10;
|
||||
|
||||
weather.temperature = this.#toNumber(hourData.temp_c);
|
||||
weather.precipitationAmount = this.#toNumber(hourData.precip_mm);
|
||||
|
||||
const willRain = this.#toNumber(hourData.will_it_rain) ?? 0;
|
||||
const willSnow = this.#toNumber(hourData.will_it_snow) ?? 0;
|
||||
weather.precipitationProbability = (willRain + willSnow) * 50;
|
||||
|
||||
weather.uv_index = this.#toNumber(hourData.uv);
|
||||
|
||||
hours.push(weather);
|
||||
|
||||
if (hours.length >= this.config.maxEntries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hours.length >= this.config.maxEntries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#convertWeatherType (weatherCode, isDayTime) {
|
||||
const weatherConditions = {
|
||||
1000: { day: "day-sunny", night: "night-clear" },
|
||||
1003: { day: "day-cloudy", night: "night-alt-cloudy" },
|
||||
1006: { day: "day-cloudy", night: "night-alt-cloudy" },
|
||||
1009: { day: "day-sunny-overcast", night: "night-alt-partly-cloudy" },
|
||||
1030: { day: "day-fog", night: "night-fog" },
|
||||
1063: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1066: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1069: { day: "day-sleet", night: "night-sleet" },
|
||||
1072: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1087: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1114: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1117: { day: "windy", night: "windy" },
|
||||
1135: { day: "day-fog", night: "night-fog" },
|
||||
1147: { day: "day-fog", night: "night-fog" },
|
||||
1150: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1153: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1168: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1171: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1180: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1183: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1186: { day: "day-showers", night: "night-showers" },
|
||||
1189: { day: "day-showers", night: "night-showers" },
|
||||
1192: { day: "day-showers", night: "night-showers" },
|
||||
1195: { day: "day-showers", night: "night-showers" },
|
||||
1198: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1201: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1204: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1207: { day: "day-showers", night: "night-showers" },
|
||||
1210: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1213: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1216: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1219: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1222: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1225: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1237: { day: "day-sleet", night: "night-sleet" },
|
||||
1240: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1243: { day: "day-showers", night: "night-showers" },
|
||||
1246: { day: "day-showers", night: "night-showers" },
|
||||
1249: { day: "day-showers", night: "night-showers" },
|
||||
1252: { day: "day-showers", night: "night-showers" },
|
||||
1255: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1258: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1261: { day: "day-sleet", night: "night-sleet" },
|
||||
1264: { day: "day-sleet", night: "night-sleet" },
|
||||
1273: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1276: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1279: { day: "day-snow-thunderstorm", night: "night-snow-thunderstorm" },
|
||||
1282: { day: "day-snow-thunderstorm", night: "night-snow-thunderstorm" }
|
||||
};
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(weatherConditions, weatherCode)) {
|
||||
return "na";
|
||||
}
|
||||
|
||||
return weatherConditions[weatherCode][isDayTime ? "day" : "night"];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherAPIProvider;
|
||||
@@ -1,292 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Weatherbit weather provider
|
||||
* See: https://www.weatherbit.io/
|
||||
*/
|
||||
class WeatherbitProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://api.weatherbit.io/v2.0",
|
||||
apiKey: "",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") {
|
||||
Log.error("[weatherbit] No API key configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Weatherbit API key required. Get one at https://www.weatherbit.io/",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
Accept: "application/json"
|
||||
},
|
||||
logContext: "weatherprovider.weatherbit"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weatherbit] Parse error:", 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
const endpoint = this.#getWeatherEndpoint();
|
||||
return `${this.config.apiBase}${endpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`;
|
||||
}
|
||||
|
||||
#getWeatherEndpoint () {
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
return "/forecast/hourly";
|
||||
case "daily":
|
||||
case "forecast":
|
||||
return "/forecast/daily";
|
||||
case "current":
|
||||
default:
|
||||
return "/current";
|
||||
}
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
Log.error("[weatherbit] No usable data received");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "No usable data in API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let weatherData = null;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(data);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[weatherbit] Unknown weather type: ${this.config.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
if (!data.data[0] || typeof data.data[0].temp === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = data.data[0];
|
||||
|
||||
const weather = {
|
||||
date: new Date(current.ts * 1000),
|
||||
temperature: parseFloat(current.temp),
|
||||
humidity: parseFloat(current.rh),
|
||||
windSpeed: parseFloat(current.wind_spd),
|
||||
windFromDirection: current.wind_dir || null,
|
||||
weatherType: this.#convertWeatherType(current.weather.icon),
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
// Parse sunrise/sunset from HH:mm format (already in local time)
|
||||
if (current.sunrise) {
|
||||
const [hours, minutes] = current.sunrise.split(":");
|
||||
const sunrise = new Date(current.ts * 1000);
|
||||
sunrise.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||
weather.sunrise = sunrise;
|
||||
}
|
||||
|
||||
if (current.sunset) {
|
||||
const [hours, minutes] = current.sunset.split(":");
|
||||
const sunset = new Date(current.ts * 1000);
|
||||
sunset.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||
weather.sunset = sunset;
|
||||
}
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
const days = [];
|
||||
|
||||
for (const forecast of data.data) {
|
||||
days.push({
|
||||
date: new Date(forecast.datetime),
|
||||
minTemperature: forecast.min_temp !== undefined ? parseFloat(forecast.min_temp) : null,
|
||||
maxTemperature: forecast.max_temp !== undefined ? parseFloat(forecast.max_temp) : null,
|
||||
precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0,
|
||||
precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null,
|
||||
weatherType: this.#convertWeatherType(forecast.weather.icon)
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
const hours = [];
|
||||
|
||||
for (const forecast of data.data) {
|
||||
hours.push({
|
||||
date: new Date(forecast.timestamp_local),
|
||||
temperature: forecast.temp !== undefined ? parseFloat(forecast.temp) : null,
|
||||
precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0,
|
||||
precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null,
|
||||
windSpeed: forecast.wind_spd !== undefined ? parseFloat(forecast.wind_spd) : null,
|
||||
windFromDirection: forecast.wind_dir || null,
|
||||
weatherType: this.#convertWeatherType(forecast.weather.icon)
|
||||
});
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Weatherbit icon codes to weathericons.css icons
|
||||
* See: https://www.weatherbit.io/api/codes
|
||||
* @param {string} weatherType - Weatherbit icon code
|
||||
* @returns {string|null} Weathericons.css icon name or null
|
||||
*/
|
||||
#convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
t01d: "day-thunderstorm",
|
||||
t01n: "night-alt-thunderstorm",
|
||||
t02d: "day-thunderstorm",
|
||||
t02n: "night-alt-thunderstorm",
|
||||
t03d: "thunderstorm",
|
||||
t03n: "thunderstorm",
|
||||
t04d: "day-thunderstorm",
|
||||
t04n: "night-alt-thunderstorm",
|
||||
t05d: "day-sleet-storm",
|
||||
t05n: "night-alt-sleet-storm",
|
||||
d01d: "day-sprinkle",
|
||||
d01n: "night-alt-sprinkle",
|
||||
d02d: "day-sprinkle",
|
||||
d02n: "night-alt-sprinkle",
|
||||
d03d: "day-showers",
|
||||
d03n: "night-alt-showers",
|
||||
r01d: "day-showers",
|
||||
r01n: "night-alt-showers",
|
||||
r02d: "day-rain",
|
||||
r02n: "night-alt-rain",
|
||||
r03d: "day-rain",
|
||||
r03n: "night-alt-rain",
|
||||
r04d: "day-sprinkle",
|
||||
r04n: "night-alt-sprinkle",
|
||||
r05d: "day-showers",
|
||||
r05n: "night-alt-showers",
|
||||
r06d: "day-showers",
|
||||
r06n: "night-alt-showers",
|
||||
f01d: "day-sleet",
|
||||
f01n: "night-alt-sleet",
|
||||
s01d: "day-snow",
|
||||
s01n: "night-alt-snow",
|
||||
s02d: "day-snow-wind",
|
||||
s02n: "night-alt-snow-wind",
|
||||
s03d: "snowflake-cold",
|
||||
s03n: "snowflake-cold",
|
||||
s04d: "day-rain-mix",
|
||||
s04n: "night-alt-rain-mix",
|
||||
s05d: "day-sleet",
|
||||
s05n: "night-alt-sleet",
|
||||
s06d: "day-snow",
|
||||
s06n: "night-alt-snow",
|
||||
a01d: "day-haze",
|
||||
a01n: "dust",
|
||||
a02d: "smoke",
|
||||
a02n: "smoke",
|
||||
a03d: "day-haze",
|
||||
a03n: "dust",
|
||||
a04d: "dust",
|
||||
a04n: "dust",
|
||||
a05d: "day-fog",
|
||||
a05n: "night-fog",
|
||||
a06d: "fog",
|
||||
a06n: "fog",
|
||||
c01d: "day-sunny",
|
||||
c01n: "night-clear",
|
||||
c02d: "day-sunny-overcast",
|
||||
c02n: "night-alt-partly-cloudy",
|
||||
c03d: "day-cloudy",
|
||||
c03n: "night-alt-cloudy",
|
||||
c04d: "cloudy",
|
||||
c04n: "cloudy",
|
||||
u00d: "rain-mix",
|
||||
u00n: "rain-mix"
|
||||
};
|
||||
|
||||
return weatherTypes[weatherType] || null;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherbitProvider;
|
||||
@@ -1,298 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const { convertKmhToMs } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* WeatherFlow weather provider
|
||||
* This class is a provider for WeatherFlow personal weather stations.
|
||||
* Note that the WeatherFlow API does not provide snowfall.
|
||||
*/
|
||||
class WeatherFlowProvider {
|
||||
|
||||
/**
|
||||
* @param {object} config - Provider configuration
|
||||
*/
|
||||
constructor (config) {
|
||||
this.config = config;
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the callbacks for data and errors
|
||||
* @param {(data: object) => void} onDataCallback - Called when new data is available
|
||||
* @param {(error: object) => void} onErrorCallback - Called when an error occurs
|
||||
*/
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the provider
|
||||
*/
|
||||
initialize () {
|
||||
if (!this.config.token || this.config.token === "YOUR_API_TOKEN_HERE") {
|
||||
Log.error("[weatherflow] No API token configured. Get one at https://tempestwx.com/");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "WeatherFlow API token required. Get one at https://tempestwx.com/",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.stationid) {
|
||||
Log.error("[weatherflow] No station ID configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "WeatherFlow station ID required",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the HTTP fetcher
|
||||
*/
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
Accept: "application/json"
|
||||
},
|
||||
logContext: "weatherprovider.weatherflow"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
const processed = this.#processData(data);
|
||||
this.onDataCallback(processed);
|
||||
} catch (error) {
|
||||
Log.error("[weatherflow] Failed to parse JSON:", error);
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
// HTTPFetcher already logged the error with logContext
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the URL for API requests
|
||||
* @returns {string} The API URL
|
||||
*/
|
||||
#getUrl () {
|
||||
const base = this.config.apiBase || "https://swd.weatherflow.com/swd/rest/";
|
||||
return `${base}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the raw API data
|
||||
* @param {object} data - Raw API response
|
||||
* @returns {object} Processed weather data
|
||||
*/
|
||||
#processData (data) {
|
||||
try {
|
||||
let weatherData;
|
||||
if (this.config.type === "current") {
|
||||
weatherData = this.#generateCurrent(data);
|
||||
} else if (this.config.type === "hourly") {
|
||||
weatherData = this.#generateHourly(data);
|
||||
} else {
|
||||
weatherData = this.#generateDaily(data);
|
||||
}
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
Log.error("[weatherflow] Data processing error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to process weather data",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate current weather data
|
||||
* @param {object} data - API response data
|
||||
* @returns {object} Current weather object
|
||||
*/
|
||||
#generateCurrent (data) {
|
||||
if (!data || !data.current_conditions || !data.forecast || !Array.isArray(data.forecast.daily) || data.forecast.daily.length === 0) {
|
||||
Log.error("[weatherflow] Invalid current weather data structure");
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = data.current_conditions;
|
||||
const daily = data.forecast.daily[0];
|
||||
|
||||
const weather = {
|
||||
date: new Date(),
|
||||
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,
|
||||
weatherType: this.#convertWeatherType(current.icon),
|
||||
uvIndex: current.uv || null,
|
||||
sunrise: daily.sunrise ? new Date(daily.sunrise * 1000) : null,
|
||||
sunset: daily.sunset ? new Date(daily.sunset * 1000) : null
|
||||
};
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate forecast data
|
||||
* @param {object} data - API response data
|
||||
* @returns {Array} Array of forecast objects
|
||||
*/
|
||||
#generateDaily (data) {
|
||||
if (!data || !data.forecast || !Array.isArray(data.forecast.daily) || !Array.isArray(data.forecast.hourly)) {
|
||||
Log.error("[weatherflow] Invalid forecast data structure");
|
||||
return [];
|
||||
}
|
||||
|
||||
const days = [];
|
||||
|
||||
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,
|
||||
weatherType: this.#convertWeatherType(forecast.icon),
|
||||
precipitationAmount: 0.0,
|
||||
precipitationUnits: "mm",
|
||||
uvIndex: 0
|
||||
};
|
||||
|
||||
// Build UV and precipitation from hourly data
|
||||
for (const hour of data.forecast.hourly) {
|
||||
const hourDate = new Date(hour.time * 1000);
|
||||
const forecastDate = new Date(forecast.day_start_local * 1000);
|
||||
|
||||
// Compare year, month, and day to ensure correct matching across month boundaries
|
||||
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;
|
||||
} else if (hourDate > forecastDate) {
|
||||
// Check if we've moved to the next day
|
||||
const diffMs = hourDate - forecastDate;
|
||||
if (diffMs >= 86400000) break; // 24 hours in ms
|
||||
}
|
||||
}
|
||||
|
||||
days.push(weather);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hourly forecast data
|
||||
* @param {object} data - API response data
|
||||
* @returns {Array} Array of hourly forecast objects
|
||||
*/
|
||||
#generateHourly (data) {
|
||||
if (!data || !data.forecast || !Array.isArray(data.forecast.hourly)) {
|
||||
Log.error("[weatherflow] Invalid hourly data structure");
|
||||
return [];
|
||||
}
|
||||
|
||||
const hours = [];
|
||||
|
||||
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,
|
||||
windSpeed: hour.wind_avg != null ? convertKmhToMs(hour.wind_avg) : null,
|
||||
windFromDirection: hour.wind_direction || null,
|
||||
weatherType: this.#convertWeatherType(hour.icon),
|
||||
precipitationProbability: hour.precip_probability || null,
|
||||
precipitationAmount: hour.precip || 0,
|
||||
precipitationUnits: "mm",
|
||||
uvIndex: hour.uv || null
|
||||
};
|
||||
|
||||
hours.push(weather);
|
||||
|
||||
// WeatherFlow provides 10 days of hourly data, trim to 48 hours
|
||||
if (hours.length >= 48) break;
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert weather icon type
|
||||
* @param {string} weatherType - WeatherFlow icon code
|
||||
* @returns {string} Weather icon CSS class
|
||||
*/
|
||||
#convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
"clear-day": "day-sunny",
|
||||
"clear-night": "night-clear",
|
||||
cloudy: "cloudy",
|
||||
foggy: "fog",
|
||||
"partly-cloudy-day": "day-cloudy",
|
||||
"partly-cloudy-night": "night-alt-cloudy",
|
||||
"possibly-rainy-day": "day-rain",
|
||||
"possibly-rainy-night": "night-alt-rain",
|
||||
"possibly-sleet-day": "day-sleet",
|
||||
"possibly-sleet-night": "night-alt-sleet",
|
||||
"possibly-snow-day": "day-snow",
|
||||
"possibly-snow-night": "night-alt-snow",
|
||||
"possibly-thunderstorm-day": "day-thunderstorm",
|
||||
"possibly-thunderstorm-night": "night-alt-thunderstorm",
|
||||
rainy: "rain",
|
||||
sleet: "sleet",
|
||||
snow: "snow",
|
||||
thunderstorm: "thunderstorm",
|
||||
windy: "strong-wind"
|
||||
};
|
||||
|
||||
return weatherTypes[weatherType] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start fetching data
|
||||
*/
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop fetching data
|
||||
*/
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherFlowProvider;
|
||||
@@ -1,416 +0,0 @@
|
||||
const Log = require("logger");
|
||||
const { getSunTimes, isDayTime, getDateString, convertKmhToMs, cardinalToDegrees } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Weather.gov (US National Weather Service)
|
||||
* Note: Only works for US locations, no API key required
|
||||
* https://weather-gov.github.io/api/general-faqs
|
||||
*/
|
||||
class WeatherGovProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://api.weather.gov/points/",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
this.locationName = null;
|
||||
this.initRetryCount = 0;
|
||||
this.initRetryTimer = null;
|
||||
|
||||
// Weather.gov specific URLs (fetched during initialization)
|
||||
this.forecastURL = null;
|
||||
this.forecastHourlyURL = null;
|
||||
this.forecastGridDataURL = null;
|
||||
this.observationStationsURL = null;
|
||||
this.stationObsURL = null;
|
||||
}
|
||||
|
||||
async initialize () {
|
||||
// Add small random delay to prevent all instances from starting simultaneously
|
||||
// This reduces parallel DNS lookups which can cause EAI_AGAIN errors
|
||||
const staggerDelay = Math.random() * 3000; // 0-3 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, staggerDelay));
|
||||
|
||||
try {
|
||||
await this.#fetchWeatherGovURLs();
|
||||
this.#initializeFetcher();
|
||||
this.initRetryCount = 0; // Reset on success
|
||||
} catch (error) {
|
||||
const errorInfo = this.#categorizeError(error);
|
||||
Log.error(`[weathergov] Initialization failed: ${errorInfo.message}`);
|
||||
|
||||
// Retry on temporary errors (DNS, timeout, network)
|
||||
if (errorInfo.isRetryable && this.initRetryCount < 5) {
|
||||
this.initRetryCount++;
|
||||
const delay = HTTPFetcher.calculateBackoffDelay(this.initRetryCount);
|
||||
Log.info(`[weathergov] Will retry initialization in ${Math.round(delay / 1000)}s (attempt ${this.initRetryCount}/5)`);
|
||||
this.initRetryTimer = setTimeout(() => this.initialize(), delay);
|
||||
} else if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: errorInfo.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#categorizeError (error) {
|
||||
const cause = error.cause || error;
|
||||
const code = cause.code || "";
|
||||
|
||||
if (code === "EAI_AGAIN" || code === "ENOTFOUND") {
|
||||
return {
|
||||
message: "DNS lookup failed for api.weather.gov - check your internet connection",
|
||||
isRetryable: true
|
||||
};
|
||||
}
|
||||
if (code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "ECONNRESET") {
|
||||
return {
|
||||
message: `Network error: ${code} - api.weather.gov may be temporarily unavailable`,
|
||||
isRetryable: true
|
||||
};
|
||||
}
|
||||
if (error.name === "AbortError") {
|
||||
return {
|
||||
message: "Request timeout - api.weather.gov is responding slowly",
|
||||
isRetryable: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: error.message || "Unknown error",
|
||||
isRetryable: false
|
||||
};
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
if (this.initRetryTimer) {
|
||||
clearTimeout(this.initRetryTimer);
|
||||
this.initRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchWeatherGovURLs () {
|
||||
// Step 1: Get grid point data
|
||||
const pointsUrl = `${this.config.apiBase}${this.config.lat},${this.config.lon}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120000); // 120 second timeout - DNS can be slow
|
||||
|
||||
try {
|
||||
const pointsResponse = await fetch(pointsUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/geo+json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!pointsResponse.ok) {
|
||||
throw new Error(`Failed to fetch grid point: HTTP ${pointsResponse.status}`);
|
||||
}
|
||||
|
||||
const pointsData = await pointsResponse.json();
|
||||
|
||||
if (!pointsData || !pointsData.properties) {
|
||||
throw new Error("Invalid grid point data");
|
||||
}
|
||||
|
||||
// Extract location name
|
||||
const relLoc = pointsData.properties.relativeLocation?.properties;
|
||||
if (relLoc) {
|
||||
this.locationName = `${relLoc.city}, ${relLoc.state}`;
|
||||
}
|
||||
|
||||
// Store forecast URLs
|
||||
this.forecastURL = `${pointsData.properties.forecast}?units=si`;
|
||||
this.forecastHourlyURL = `${pointsData.properties.forecastHourly}?units=si`;
|
||||
this.forecastGridDataURL = pointsData.properties.forecastGridData;
|
||||
this.observationStationsURL = pointsData.properties.observationStations;
|
||||
|
||||
// Step 2: Get observation station URL
|
||||
const stationsResponse = await fetch(this.observationStationsURL, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/geo+json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!stationsResponse.ok) {
|
||||
throw new Error(`Failed to fetch observation stations: HTTP ${stationsResponse.status}`);
|
||||
}
|
||||
|
||||
const stationsData = await stationsResponse.json();
|
||||
|
||||
if (!stationsData || !stationsData.features || stationsData.features.length === 0) {
|
||||
throw new Error("No observation stations found");
|
||||
}
|
||||
|
||||
this.stationObsURL = `${stationsData.features[0].id}/observations/latest`;
|
||||
|
||||
Log.log(`[weathergov] Initialized for ${this.locationName}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
let url;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
url = this.stationObsURL;
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
url = this.forecastURL;
|
||||
break;
|
||||
case "hourly":
|
||||
url = this.forecastHourlyURL;
|
||||
break;
|
||||
default:
|
||||
url = this.stationObsURL;
|
||||
}
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
timeout: 60000, // 60 seconds - weather.gov can be slow
|
||||
headers: {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/geo+json",
|
||||
"Cache-Control": "no-cache"
|
||||
},
|
||||
logContext: "weatherprovider.weathergov"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weathergov] 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
try {
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
if (!data.properties) {
|
||||
throw new Error("Invalid current weather data");
|
||||
}
|
||||
weatherData = this.#generateWeatherObjectFromCurrentWeather(data.properties);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
if (!data.properties || !data.properties.periods) {
|
||||
throw new Error("Invalid forecast data");
|
||||
}
|
||||
weatherData = this.#generateWeatherObjectsFromForecast(data.properties.periods);
|
||||
break;
|
||||
case "hourly":
|
||||
if (!data.properties || !data.properties.periods) {
|
||||
throw new Error("Invalid hourly data");
|
||||
}
|
||||
weatherData = this.#generateWeatherObjectsFromHourly(data.properties.periods);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[weathergov] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#generateWeatherObjectFromCurrentWeather (currentWeatherData) {
|
||||
const current = {};
|
||||
|
||||
current.date = new Date(currentWeatherData.timestamp);
|
||||
current.temperature = currentWeatherData.temperature.value;
|
||||
current.windSpeed = currentWeatherData.windSpeed.value; // Observations are already in m/s
|
||||
current.windFromDirection = currentWeatherData.windDirection.value;
|
||||
current.minTemperature = currentWeatherData.minTemperatureLast24Hours?.value;
|
||||
current.maxTemperature = currentWeatherData.maxTemperatureLast24Hours?.value;
|
||||
current.humidity = Math.round(currentWeatherData.relativeHumidity.value);
|
||||
current.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value;
|
||||
|
||||
// Feels like temperature
|
||||
if (currentWeatherData.heatIndex.value !== null) {
|
||||
current.feelsLikeTemp = currentWeatherData.heatIndex.value;
|
||||
} else if (currentWeatherData.windChill.value !== null) {
|
||||
current.feelsLikeTemp = currentWeatherData.windChill.value;
|
||||
} else {
|
||||
current.feelsLikeTemp = currentWeatherData.temperature.value;
|
||||
}
|
||||
|
||||
// Calculate sunrise/sunset (not provided by weather.gov)
|
||||
const { sunrise, sunset } = getSunTimes(current.date, this.config.lat, this.config.lon);
|
||||
current.sunrise = sunrise;
|
||||
current.sunset = sunset;
|
||||
|
||||
// Determine if daytime
|
||||
const isDay = isDayTime(current.date, current.sunrise, current.sunset);
|
||||
current.weatherType = this.#convertWeatherType(currentWeatherData.textDescription, isDay);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromForecast (forecasts) {
|
||||
const days = [];
|
||||
let minTemp = [];
|
||||
let maxTemp = [];
|
||||
let date = "";
|
||||
let weather = {};
|
||||
|
||||
for (const forecast of forecasts) {
|
||||
const forecastDate = new Date(forecast.startTime);
|
||||
const dateStr = getDateString(forecastDate);
|
||||
|
||||
if (date !== dateStr) {
|
||||
// New day
|
||||
if (date !== "") {
|
||||
weather.minTemperature = Math.min(...minTemp);
|
||||
weather.maxTemperature = Math.max(...maxTemp);
|
||||
days.push(weather);
|
||||
}
|
||||
|
||||
weather = {};
|
||||
minTemp = [];
|
||||
maxTemp = [];
|
||||
date = dateStr;
|
||||
|
||||
weather.date = forecastDate;
|
||||
weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0;
|
||||
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
|
||||
}
|
||||
|
||||
// Update weather type for daytime hours (8am-5pm)
|
||||
const hour = forecastDate.getHours();
|
||||
if (hour >= 8 && hour <= 17) {
|
||||
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
|
||||
}
|
||||
|
||||
minTemp.push(forecast.temperature);
|
||||
maxTemp.push(forecast.temperature);
|
||||
}
|
||||
|
||||
// Last day
|
||||
if (date !== "") {
|
||||
weather.minTemperature = Math.min(...minTemp);
|
||||
weather.maxTemperature = Math.max(...maxTemp);
|
||||
days.push(weather);
|
||||
}
|
||||
|
||||
return days.slice(1); // Skip first incomplete day
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromHourly (forecasts) {
|
||||
const hours = [];
|
||||
|
||||
for (const forecast of forecasts) {
|
||||
const weather = {};
|
||||
|
||||
weather.date = new Date(forecast.startTime);
|
||||
|
||||
// Parse wind speed
|
||||
const windSpeedStr = forecast.windSpeed;
|
||||
let windSpeed = windSpeedStr;
|
||||
if (windSpeedStr.includes(" ")) {
|
||||
windSpeed = windSpeedStr.split(" ")[0];
|
||||
}
|
||||
weather.windSpeed = convertKmhToMs(parseFloat(windSpeed));
|
||||
weather.windFromDirection = cardinalToDegrees(forecast.windDirection);
|
||||
weather.temperature = forecast.temperature;
|
||||
weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0;
|
||||
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
|
||||
|
||||
hours.push(weather);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#convertWeatherType (weatherType, isDaytime) {
|
||||
// https://w1.weather.gov/xml/current_obs/weather.php
|
||||
|
||||
if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) {
|
||||
return isDaytime ? "day-cloudy" : "night-cloudy";
|
||||
} else if (weatherType.includes("Overcast")) {
|
||||
return isDaytime ? "cloudy" : "night-cloudy";
|
||||
} else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) {
|
||||
return "rain-mix";
|
||||
} else if (weatherType.includes("Snow")) {
|
||||
return isDaytime ? "snow" : "night-snow";
|
||||
} else if (weatherType.includes("Thunderstorm")) {
|
||||
return isDaytime ? "thunderstorm" : "night-thunderstorm";
|
||||
} else if (weatherType.includes("Showers")) {
|
||||
return isDaytime ? "showers" : "night-showers";
|
||||
} else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) {
|
||||
return isDaytime ? "rain" : "night-rain";
|
||||
} else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) {
|
||||
return isDaytime ? "cloudy-windy" : "night-alt-cloudy-windy";
|
||||
} else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) {
|
||||
return isDaytime ? "day-sunny" : "night-clear";
|
||||
} else if (weatherType.includes("Dust") || weatherType.includes("Sand")) {
|
||||
return "dust";
|
||||
} else if (weatherType.includes("Fog")) {
|
||||
return "fog";
|
||||
} else if (weatherType.includes("Smoke")) {
|
||||
return "smoke";
|
||||
} else if (weatherType.includes("Haze")) {
|
||||
return "day-haze";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherGovProvider;
|
||||
@@ -1,469 +0,0 @@
|
||||
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;
|
||||
@@ -3,13 +3,13 @@ import globals from "globals";
|
||||
import {flatConfigs as importX} from "eslint-plugin-import-x";
|
||||
import js from "@eslint/js";
|
||||
import jsdocPlugin from "eslint-plugin-jsdoc";
|
||||
import {configs as packageJsonConfigs} from "eslint-plugin-package-json";
|
||||
import packageJson from "eslint-plugin-package-json";
|
||||
import playwright from "eslint-plugin-playwright";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import vitest from "@vitest/eslint-plugin";
|
||||
import vitest from "eslint-plugin-vitest";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["config/**", "modules/**/*", "js/positions.js", "tests/configs/config_variables.js"]),
|
||||
globalIgnores(["config/**", "modules/**/*", "!modules/default/**", "js/positions.js"]),
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
languageOptions: {
|
||||
@@ -17,6 +17,7 @@ export default defineConfig([
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...vitest.environments.env.globals,
|
||||
Log: "readonly",
|
||||
MM: "readonly",
|
||||
Module: "readonly",
|
||||
@@ -24,11 +25,13 @@ export default defineConfig([
|
||||
moment: "readonly"
|
||||
}
|
||||
},
|
||||
extends: [importX.recommended, js.configs.recommended, jsdocPlugin.configs["flat/recommended"], stylistic.configs.all],
|
||||
plugins: {js, stylistic, vitest},
|
||||
extends: [importX.recommended, vitest.configs.recommended, "js/recommended", jsdocPlugin.configs["flat/recommended"], "stylistic/all"],
|
||||
rules: {
|
||||
"@stylistic/array-element-newline": ["error", "consistent"],
|
||||
"@stylistic/arrow-parens": ["error", "always"],
|
||||
"@stylistic/brace-style": "off",
|
||||
"@stylistic/comma-dangle": ["error", "never"],
|
||||
"@stylistic/dot-location": ["error", "property"],
|
||||
"@stylistic/function-call-argument-newline": ["error", "consistent"],
|
||||
"@stylistic/function-paren-newline": ["error", "consistent"],
|
||||
@@ -50,12 +53,29 @@ export default defineConfig([
|
||||
"@stylistic/space-before-function-paren": ["error", "always"],
|
||||
"@stylistic/spaced-comment": "off",
|
||||
"dot-notation": "error",
|
||||
eqeqeq: ["error", "always", {null: "ignore"}],
|
||||
eqeqeq: "error",
|
||||
"id-length": "off",
|
||||
"import-x/extensions": "error",
|
||||
"import-x/newline-after-import": "error",
|
||||
"import-x/order": "error",
|
||||
"init-declarations": "off",
|
||||
"vitest/consistent-test-it": "warn",
|
||||
"vitest/expect-expect": [
|
||||
"warn",
|
||||
{
|
||||
assertFunctionNames: [
|
||||
"expect",
|
||||
"testElementLength",
|
||||
"testTextContain",
|
||||
"doTest",
|
||||
"runAnimationTest",
|
||||
"waitForAnimationClass",
|
||||
"assertNoAnimationWithin"
|
||||
]
|
||||
}
|
||||
],
|
||||
"vitest/prefer-to-be": "warn",
|
||||
"vitest/prefer-to-have-length": "warn",
|
||||
"max-lines-per-function": ["warn", 400],
|
||||
"max-statements": "off",
|
||||
"no-global-assign": "off",
|
||||
@@ -74,7 +94,6 @@ export default defineConfig([
|
||||
"object-shorthand": ["error", "methods"],
|
||||
"one-var": "off",
|
||||
"prefer-template": "error",
|
||||
"require-await": "error",
|
||||
"sort-keys": "off"
|
||||
}
|
||||
},
|
||||
@@ -89,7 +108,8 @@ export default defineConfig([
|
||||
},
|
||||
{
|
||||
files: ["**/package.json"],
|
||||
extends: [packageJsonConfigs.recommended]
|
||||
plugins: {packageJson},
|
||||
extends: ["packageJson/recommended"]
|
||||
},
|
||||
{
|
||||
files: ["**/*.mjs"],
|
||||
@@ -100,7 +120,8 @@ export default defineConfig([
|
||||
},
|
||||
sourceType: "module"
|
||||
},
|
||||
extends: [importX.recommended, js.configs.all, stylistic.configs.all],
|
||||
plugins: {js, stylistic},
|
||||
extends: [importX.recommended, "js/all", "stylistic/all"],
|
||||
rules: {
|
||||
"@stylistic/array-element-newline": "off",
|
||||
"@stylistic/indent": ["error", "tab"],
|
||||
@@ -114,45 +135,6 @@ export default defineConfig([
|
||||
"sort-keys": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...vitest.environments.env.globals
|
||||
}
|
||||
},
|
||||
extends: [vitest.configs.recommended],
|
||||
rules: {
|
||||
"vitest/consistent-test-it": "error",
|
||||
"vitest/expect-expect": [
|
||||
"error",
|
||||
{
|
||||
assertFunctionNames: [
|
||||
"expect",
|
||||
"testElementLength",
|
||||
"testTextContain",
|
||||
"doTest",
|
||||
"runAnimationTest",
|
||||
"waitForAnimationClass",
|
||||
"assertNoAnimationWithin"
|
||||
]
|
||||
}
|
||||
],
|
||||
"vitest/max-nested-describe": ["error", {max: 3}],
|
||||
"vitest/prefer-to-be": "error",
|
||||
"vitest/prefer-to-have-length": "error",
|
||||
"max-lines-per-function": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["tests/unit/modules/default/weather/providers/*.js"],
|
||||
rules: {
|
||||
"import-x/namespace": "off",
|
||||
"import-x/named": "off",
|
||||
"import-x/default": "off",
|
||||
"import-x/extensions": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["tests/configs/modules/weather/*.js"],
|
||||
rules: {
|
||||
@@ -163,13 +145,6 @@ export default defineConfig([
|
||||
files: ["tests/e2e/**/*.js"],
|
||||
extends: [playwright.configs["flat/recommended"]],
|
||||
rules: {
|
||||
|
||||
/*
|
||||
* Tests use Vitest-style plain beforeAll()/afterAll() calls, not Playwright's
|
||||
* test.beforeAll() style. The rule incorrectly treats all plain hook calls
|
||||
* as the same unnamed type, flagging the second hook as a duplicate.
|
||||
*/
|
||||
"playwright/no-duplicate-hooks": "off",
|
||||
"playwright/no-standalone-expect": "off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<path fill="#797676" d="M9.18 35.96 3.13 16.72h-.19l.16 2.57.03 3.38v13.3H1V14h3.45l5.68 18h.15l5.8-18h3.4v22h-2.26V19.29l.15-2.57h-.12L11.08 36Zm20.84 0-6.06-19.24h-.08l.15 2.57.04 3.38v13.3H21.9V14h3.45l5.68 18h.12L37 14h3.41v22H38.1V22.48l.04-3.2.15-2.56h-.12L31.96 36Z"/>
|
||||
<path fill="#a6a3a3" d="M47 19.84h-5.73v-1.1l2.23-2.83q.67-.8 1.12-1.43.45-.6.67-1.21.22-.64.22-1.36 0-.87-.41-1.32-.44-.46-1.11-.46-.63 0-1.12.27-.45.26-.93.75l-.6-.9q.53-.5 1.16-.87.67-.38 1.49-.38 1.23 0 1.9.76.74.75.74 2.07 0 .87-.3 1.59-.3.75-.79 1.43-.47.76-1.14 1.51l-1.8 2.23v.04h4.36Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 646 B |
@@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<link rel="icon" href="favicon.svg" />
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
|
||||
<link rel="stylesheet" type="text/css" href="css/main.css" />
|
||||
<link rel="stylesheet" type="text/css" href="css/roboto.css" />
|
||||
<link rel="stylesheet" type="text/css" href="node_modules/animate.css/animate.min.css" />
|
||||
@@ -44,14 +44,14 @@
|
||||
<script type="text/javascript" src="socket.io/socket.io.js"></script>
|
||||
<script type="text/javascript" src="node_modules/nunjucks/browser/nunjucks.min.js"></script>
|
||||
<script type="text/javascript" src="js/defaults.js"></script>
|
||||
<script type="text/javascript" src="#CONFIG_FILE#"></script>
|
||||
<script type="text/javascript" src="js/vendor.js"></script>
|
||||
<script type="text/javascript" src="defaultmodules/defaultmodules.js"></script>
|
||||
<script type="text/javascript" src="defaultmodules/utils.js"></script>
|
||||
<script type="text/javascript" src="modules/default/defaultmodules.js"></script>
|
||||
<script type="text/javascript" src="modules/default/utils.js"></script>
|
||||
<script type="text/javascript" src="js/logger.js"></script>
|
||||
<script type="text/javascript" src="translations/translations.js"></script>
|
||||
<script type="text/javascript" src="js/translator.js"></script>
|
||||
<script type="text/javascript" src="js/class.js"></script>
|
||||
<script type="text/javascript" src="config/basepath.js"></script>
|
||||
<script type="text/javascript" src="js/module.js"></script>
|
||||
<script type="text/javascript" src="js/loader.js"></script>
|
||||
<script type="text/javascript" src="js/socketclient.js"></script>
|
||||
|
||||
41
jest.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const aliasMapper = {
|
||||
logger: "<rootDir>/js/logger.js"
|
||||
};
|
||||
|
||||
const config = {
|
||||
verbose: true,
|
||||
testTimeout: 20000,
|
||||
testSequencer: "<rootDir>/tests/utils/test_sequencer.js",
|
||||
projects: [
|
||||
{
|
||||
displayName: "unit",
|
||||
globalSetup: "<rootDir>/tests/unit/helpers/global-setup.js",
|
||||
moduleNameMapper: aliasMapper,
|
||||
testMatch: ["**/tests/unit/**/*.[jt]s?(x)"],
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/unit/mocks", "<rootDir>/tests/unit/helpers"]
|
||||
},
|
||||
{
|
||||
displayName: "electron",
|
||||
testMatch: ["**/tests/electron/**/*.[jt]s?(x)"],
|
||||
moduleNameMapper: aliasMapper,
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/electron/helpers"]
|
||||
},
|
||||
{
|
||||
displayName: "e2e",
|
||||
testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"],
|
||||
modulePaths: ["<rootDir>/js/"],
|
||||
moduleNameMapper: aliasMapper,
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/e2e/helpers", "<rootDir>/tests/e2e/mocks"]
|
||||
}
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
"<rootDir>/clientonly/**/*.js",
|
||||
"<rootDir>/js/**/*.js",
|
||||
"<rootDir>/modules/default/**/*.js",
|
||||
"<rootDir>/serveronly/**/*.js"
|
||||
],
|
||||
coverageReporters: ["lcov", "text"],
|
||||
coverageProvider: "v8"
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -3,7 +3,7 @@
|
||||
// For a future ESM migration, replace this with a public export/import surface.
|
||||
|
||||
const path = require("node:path");
|
||||
const Module = require("node:module");
|
||||
const Module = require("module");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
|
||||
|
||||
155
js/app.js
@@ -3,19 +3,19 @@ require("./alias-resolver");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const Spawn = require("node:child_process").spawn;
|
||||
const envsub = require("envsub");
|
||||
const Log = require("logger");
|
||||
|
||||
// global absolute root path
|
||||
global.root_path = path.resolve(`${__dirname}/../`);
|
||||
|
||||
const Server = require(`${__dirname}/server`);
|
||||
const Utils = require(`${__dirname}/utils`);
|
||||
|
||||
const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);
|
||||
// used to control fetch timeout for node_helpers
|
||||
const { setGlobalDispatcher, Agent } = require("undici");
|
||||
|
||||
const Server = require("./server");
|
||||
const Utils = require("./utils");
|
||||
|
||||
const { getEnvVarsAsObj } = require("#server_functions");
|
||||
const { getEnvVarsAsObj, getConfigFilePath } = require("#server_functions");
|
||||
// common timeout value, provide environment override in case
|
||||
const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;
|
||||
|
||||
@@ -25,7 +25,7 @@ global.mmTestMode = process.env.mmTestMode === "true";
|
||||
Log.log(`Starting MagicMirror: v${global.version}`);
|
||||
|
||||
// Log system information.
|
||||
Spawn("node ./js/systeminformation.js", { env: { ...process.env, ELECTRON_VERSION: `${process.versions.electron}` }, cwd: this.root_path, shell: true, detached: true, stdio: "inherit" });
|
||||
Utils.logSystemInformation(global.version);
|
||||
|
||||
if (process.env.MM_CONFIG_FILE) {
|
||||
global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, "");
|
||||
@@ -56,8 +56,122 @@ process.on("uncaughtException", function (err) {
|
||||
function App () {
|
||||
let nodeHelpers = [];
|
||||
let httpServer;
|
||||
let defaultModules;
|
||||
let env;
|
||||
|
||||
/**
|
||||
* Loads the config file. Combines it with the defaults and returns the config
|
||||
* @async
|
||||
* @returns {Promise<object>} the loaded config or the defaults if something goes wrong
|
||||
*/
|
||||
async function loadConfig () {
|
||||
Log.log("Loading config ...");
|
||||
const defaults = require(`${__dirname}/defaults`);
|
||||
if (global.mmTestMode) {
|
||||
// if we are running in test mode
|
||||
defaults.address = "0.0.0.0";
|
||||
}
|
||||
|
||||
// For this check proposed to TestSuite
|
||||
// https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8
|
||||
const configFilename = getConfigFilePath();
|
||||
let templateFile = `${configFilename}.template`;
|
||||
|
||||
// check if templateFile exists
|
||||
try {
|
||||
fs.accessSync(templateFile, fs.constants.F_OK);
|
||||
} catch (err) {
|
||||
templateFile = null;
|
||||
Log.log("config template file not exists, no envsubst");
|
||||
}
|
||||
|
||||
if (templateFile) {
|
||||
// save current config.js
|
||||
try {
|
||||
if (fs.existsSync(configFilename)) {
|
||||
fs.copyFileSync(configFilename, `${configFilename}-old`);
|
||||
}
|
||||
} catch (err) {
|
||||
Log.warn(`Could not copy ${configFilename}: ${err.message}`);
|
||||
}
|
||||
|
||||
// check if config.env exists
|
||||
const envFiles = [];
|
||||
const configEnvFile = `${configFilename.substr(0, configFilename.lastIndexOf("."))}.env`;
|
||||
try {
|
||||
if (fs.existsSync(configEnvFile)) {
|
||||
envFiles.push(configEnvFile);
|
||||
}
|
||||
} catch (err) {
|
||||
Log.log(`${configEnvFile} does not exist. ${err.message}`);
|
||||
}
|
||||
|
||||
let options = {
|
||||
all: true,
|
||||
diff: false,
|
||||
envFiles: envFiles,
|
||||
protect: false,
|
||||
syntax: "default",
|
||||
system: true
|
||||
};
|
||||
|
||||
// envsubst variables in templateFile and create new config.js
|
||||
// naming for envsub must be templateFile and outputFile
|
||||
const outputFile = configFilename;
|
||||
try {
|
||||
await envsub({ templateFile, outputFile, options });
|
||||
} catch (err) {
|
||||
Log.error(`Could not envsubst variables: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
require(`${global.root_path}/js/check_config.js`);
|
||||
|
||||
try {
|
||||
fs.accessSync(configFilename, fs.constants.F_OK);
|
||||
const c = require(configFilename);
|
||||
if (Object.keys(c).length === 0) {
|
||||
Log.error("WARNING! Config file appears empty, maybe missing module.exports last line?");
|
||||
}
|
||||
checkDeprecatedOptions(c);
|
||||
return Object.assign(defaults, c);
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") {
|
||||
Log.error("WARNING! Could not find config file. Please create one. Starting with default configuration.");
|
||||
} else if (e instanceof ReferenceError || e instanceof SyntaxError) {
|
||||
Log.error(`WARNING! Could not validate config file. Starting with default configuration. Please correct syntax errors at or above this line: ${e.stack}`);
|
||||
} else {
|
||||
Log.error(`WARNING! Could not load config file. Starting with default configuration. Error found: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the config for deprecated options and throws a warning in the logs
|
||||
* if it encounters one option from the deprecated.js list
|
||||
* @param {object} userConfig The user config
|
||||
*/
|
||||
function checkDeprecatedOptions (userConfig) {
|
||||
const deprecated = require(`${global.root_path}/js/deprecated`);
|
||||
|
||||
// check for deprecated core options
|
||||
const deprecatedOptions = deprecated.configs;
|
||||
const usedDeprecated = deprecatedOptions.filter((option) => userConfig.hasOwnProperty(option));
|
||||
if (usedDeprecated.length > 0) {
|
||||
Log.warn(`WARNING! Your config is using deprecated option(s): ${usedDeprecated.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
|
||||
}
|
||||
|
||||
// check for deprecated module options
|
||||
for (const element of userConfig.modules) {
|
||||
if (deprecated[element.module] !== undefined && element.config !== undefined) {
|
||||
const deprecatedModuleOptions = deprecated[element.module];
|
||||
const usedDeprecatedModuleOptions = deprecatedModuleOptions.filter((option) => element.config.hasOwnProperty(option));
|
||||
if (usedDeprecatedModuleOptions.length > 0) {
|
||||
Log.warn(`WARNING! Your config for module ${element.module} is using deprecated option(s): ${usedDeprecatedModuleOptions.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a specific module.
|
||||
@@ -66,10 +180,11 @@ function App () {
|
||||
function loadModule (module) {
|
||||
const elements = module.split("/");
|
||||
const moduleName = elements[elements.length - 1];
|
||||
const env = getEnvVarsAsObj();
|
||||
let moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module);
|
||||
|
||||
if (defaultModules.includes(moduleName)) {
|
||||
const defaultModuleFolder = path.resolve(`${global.root_path}/${global.defaultModulesDir}/`, module);
|
||||
const defaultModuleFolder = path.resolve(`${global.root_path}/modules/default/`, module);
|
||||
if (!global.mmTestMode) {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
} else {
|
||||
@@ -173,26 +288,10 @@ function App () {
|
||||
* @returns {Promise<object>} the config used
|
||||
*/
|
||||
this.start = async function () {
|
||||
const configObj = Utils.loadConfig();
|
||||
config = configObj.fullConf;
|
||||
Utils.checkConfigFile(configObj);
|
||||
|
||||
global.defaultModulesDir = config.defaultModulesDir;
|
||||
defaultModules = require(`${global.root_path}/${global.defaultModulesDir}/defaultmodules`);
|
||||
config = await loadConfig();
|
||||
|
||||
Log.setLogLevel(config.logLevel);
|
||||
|
||||
env = getEnvVarsAsObj();
|
||||
// check for deprecated css/custom.css and move it to new location
|
||||
if ((!fs.existsSync(`${global.root_path}/${env.customCss}`)) && (fs.existsSync(`${global.root_path}/css/custom.css`))) {
|
||||
try {
|
||||
fs.renameSync(`${global.root_path}/css/custom.css`, `${global.root_path}/${env.customCss}`);
|
||||
Log.warn(`WARNING! Your custom css file was moved from ${global.root_path}/css/custom.css to ${global.root_path}/${env.customCss}`);
|
||||
} catch (err) {
|
||||
Log.warn("WARNING! Your custom css file is currently located in the css folder. Please move it to the config folder!");
|
||||
}
|
||||
}
|
||||
|
||||
// get the used module positions
|
||||
Utils.getModulePositions();
|
||||
|
||||
@@ -217,7 +316,7 @@ function App () {
|
||||
|
||||
await loadModules(modules);
|
||||
|
||||
httpServer = new Server(configObj);
|
||||
httpServer = new Server(config);
|
||||
const { app, io } = await httpServer.open();
|
||||
Log.log("Server started ...");
|
||||
|
||||
|
||||
@@ -2,13 +2,154 @@
|
||||
require("./alias-resolver");
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { styleText } = require("node:util");
|
||||
const Ajv = require("ajv");
|
||||
const globals = require("globals");
|
||||
const { Linter } = require("eslint");
|
||||
const Log = require("logger");
|
||||
|
||||
const rootPath = path.resolve(`${__dirname}/../`);
|
||||
const Utils = require(`${rootPath}/js/utils.js`);
|
||||
|
||||
const linter = new Linter({ configType: "flat" });
|
||||
const ajv = new Ajv();
|
||||
|
||||
/**
|
||||
* Returns a string with path of configuration file.
|
||||
* Check if set by environment variable MM_CONFIG_FILE
|
||||
* @returns {string} path and filename of the config file
|
||||
*/
|
||||
function getConfigFile () {
|
||||
// FIXME: This function should be in core. Do you want refactor me ;) ?, be good!
|
||||
return path.resolve(process.env.MM_CONFIG_FILE || `${rootPath}/config/config.js`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the config file using eslint.
|
||||
*/
|
||||
function checkConfigFile () {
|
||||
const configFileName = getConfigFile();
|
||||
|
||||
// Check if file exists and is accessible
|
||||
try {
|
||||
fs.accessSync(configFileName, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
Log.error(`File not found: ${configFileName}`);
|
||||
} else if (error.code === "EACCES") {
|
||||
Log.error(`No permission to read config file: ${configFileName}`);
|
||||
} else {
|
||||
Log.error(`Cannot access config file: ${configFileName}\n${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate syntax of the configuration file.
|
||||
Log.info(`Checking config file ${configFileName} ...`);
|
||||
|
||||
// I'm not sure if all ever is utf-8
|
||||
const configFile = fs.readFileSync(configFileName, "utf-8");
|
||||
|
||||
const errors = linter.verify(
|
||||
configFile,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-sparse-arrays": "error",
|
||||
"no-undef": "error"
|
||||
}
|
||||
},
|
||||
configFileName
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
Log.info(styleText("green", "Your configuration file doesn't contain syntax errors :)"));
|
||||
validateModulePositions(configFileName);
|
||||
} else {
|
||||
let errorMessage = "Your configuration file contains syntax errors :(";
|
||||
|
||||
for (const error of errors) {
|
||||
errorMessage += `\nLine ${error.line} column ${error.column}: ${error.message}`;
|
||||
}
|
||||
Log.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} configFileName - The path and filename of the configuration file to validate.
|
||||
*/
|
||||
function validateModulePositions (configFileName) {
|
||||
Log.info("Checking modules structure configuration ...");
|
||||
|
||||
const positionList = Utils.getModulePositions();
|
||||
|
||||
// Make Ajv schema configuration of modules config
|
||||
// Only scan "module" and "position"
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
modules: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
module: {
|
||||
type: "string"
|
||||
},
|
||||
position: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["module"]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Scan all modules
|
||||
const validate = ajv.compile(schema);
|
||||
const data = require(configFileName);
|
||||
|
||||
const valid = validate(data);
|
||||
if (valid) {
|
||||
Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)"));
|
||||
|
||||
// Check for unknown positions (warning only, not an error)
|
||||
if (data.modules) {
|
||||
for (const [index, module] of data.modules.entries()) {
|
||||
if (module.position && !positionList.includes(module.position)) {
|
||||
Log.warn(`Module ${index} ("${module.module}") uses unknown position: "${module.position}"`);
|
||||
Log.warn(`Known positions are: ${positionList.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const module = validate.errors[0].instancePath.split("/")[2];
|
||||
const position = validate.errors[0].instancePath.split("/")[3];
|
||||
let errorMessage = "This module configuration contains errors:";
|
||||
errorMessage += `\n${JSON.stringify(data.modules[module], null, 2)}`;
|
||||
if (position) {
|
||||
errorMessage += `\n${position}: ${validate.errors[0].message}`;
|
||||
errorMessage += `\n${JSON.stringify(validate.errors[0].params.allowedValues, null, 2).slice(1, -1)}`;
|
||||
} else {
|
||||
errorMessage += validate.errors[0].message;
|
||||
}
|
||||
Log.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Utils.checkConfigFile();
|
||||
checkConfigFile();
|
||||
} catch (error) {
|
||||
const message = error && error.message ? error.message : error;
|
||||
Log.error(`Unexpected error: ${message}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ const defaults = {
|
||||
address: address,
|
||||
port: port,
|
||||
basePath: "/",
|
||||
kioskmode: false,
|
||||
electronOptions: {},
|
||||
ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"],
|
||||
|
||||
@@ -17,10 +18,8 @@ const defaults = {
|
||||
timeFormat: 24,
|
||||
units: "metric",
|
||||
zoom: 1,
|
||||
customCss: "config/custom.css",
|
||||
customCss: "css/custom.css",
|
||||
foreignModulesDir: "modules",
|
||||
defaultModulesDir: "defaultmodules",
|
||||
hideConfigSecrets: false,
|
||||
// httpHeaders used by helmet, see https://helmetjs.github.io/. You can add other/more object values by overriding this in config.js,
|
||||
// e.g. you need to add `frameguard: false` for embedding MagicMirror in another website, see https://github.com/MagicMirrorOrg/MagicMirror/issues/2847
|
||||
httpHeaders: { contentSecurityPolicy: false, crossOriginOpenerPolicy: false, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false, originAgentCluster: false },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
configs: [],
|
||||
configs: ["kioskmode"],
|
||||
clock: ["secondsColor"]
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ function createWindow () {
|
||||
let electronOptionsDefaults = {
|
||||
width: electronSize.width,
|
||||
height: electronSize.height,
|
||||
icon: "favicon.svg",
|
||||
icon: "mm2.png",
|
||||
x: 0,
|
||||
y: 0,
|
||||
darkTheme: true,
|
||||
@@ -60,11 +60,19 @@ function createWindow () {
|
||||
backgroundColor: "#000000"
|
||||
};
|
||||
|
||||
electronOptionsDefaults.show = false;
|
||||
electronOptionsDefaults.frame = false;
|
||||
electronOptionsDefaults.transparent = true;
|
||||
electronOptionsDefaults.hasShadow = false;
|
||||
electronOptionsDefaults.fullscreen = true;
|
||||
/*
|
||||
* DEPRECATED: "kioskmode" backwards compatibility, to be removed
|
||||
* settings these options directly instead provides cleaner interface
|
||||
*/
|
||||
if (config.kioskmode) {
|
||||
electronOptionsDefaults.kiosk = true;
|
||||
} else {
|
||||
electronOptionsDefaults.show = false;
|
||||
electronOptionsDefaults.frame = false;
|
||||
electronOptionsDefaults.transparent = true;
|
||||
electronOptionsDefaults.hasShadow = false;
|
||||
electronOptionsDefaults.fullscreen = true;
|
||||
}
|
||||
|
||||
const electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions);
|
||||
|
||||
@@ -124,6 +132,22 @@ function createWindow () {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
if (config.kioskmode) {
|
||||
mainWindow.on("blur", function () {
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
mainWindow.on("leave-full-screen", function () {
|
||||
mainWindow.setFullScreen(true);
|
||||
});
|
||||
|
||||
mainWindow.on("resize", function () {
|
||||
setTimeout(function () {
|
||||
mainWindow.reload();
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
//remove response headers that prevent sites of being embedded into iframes if configured
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
let curHeaders = details.responseHeaders;
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { Agent } = require("undici");
|
||||
const Log = require("logger");
|
||||
const { getUserAgent } = require("#server_functions");
|
||||
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||
const THIRTY_MINUTES = 30 * 60 * 1000;
|
||||
const MAX_SERVER_BACKOFF = 3;
|
||||
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Maps errorType to MagicMirror translation keys.
|
||||
* This allows HTTPFetcher to provide ready-to-use translation keys,
|
||||
* eliminating the need to call NodeHelper.checkFetchError().
|
||||
*/
|
||||
const ERROR_TYPE_TO_TRANSLATION = {
|
||||
AUTH_FAILURE: "MODULE_ERROR_UNAUTHORIZED",
|
||||
RATE_LIMITED: "MODULE_ERROR_RATE_LIMITED",
|
||||
SERVER_ERROR: "MODULE_ERROR_SERVER_ERROR",
|
||||
CLIENT_ERROR: "MODULE_ERROR_CLIENT_ERROR",
|
||||
NETWORK_ERROR: "MODULE_ERROR_NO_CONNECTION",
|
||||
UNKNOWN_ERROR: "MODULE_ERROR_UNSPECIFIED"
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTPFetcher - Centralized HTTP fetching with intelligent error handling
|
||||
*
|
||||
* Features:
|
||||
* - Automatic retry strategies based on HTTP status codes
|
||||
* - Exponential backoff for server errors
|
||||
* - Retry-After header parsing for rate limiting
|
||||
* - Authentication support (Basic, Bearer)
|
||||
* - Self-signed certificate support
|
||||
* @augments EventEmitter
|
||||
* @fires HTTPFetcher#response - When fetch succeeds with ok response
|
||||
* @fires HTTPFetcher#error - When fetch fails or returns non-ok response
|
||||
* @example
|
||||
* const fetcher = new HTTPFetcher(url, { reloadInterval: 60000 });
|
||||
* fetcher.on('response', (response) => { ... });
|
||||
* fetcher.on('error', (errorInfo) => { ... });
|
||||
* fetcher.startPeriodicFetch();
|
||||
*/
|
||||
class HTTPFetcher extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Calculates exponential backoff delay for retries
|
||||
* @param {number} attempt - Attempt number (1-based)
|
||||
* @param {object} options - Configuration options
|
||||
* @param {number} [options.baseDelay] - Initial delay in ms (default: 15s)
|
||||
* @param {number} [options.maxDelay] - Maximum delay in ms (default: 5min)
|
||||
* @returns {number} Delay in milliseconds
|
||||
* @example
|
||||
* HTTPFetcher.calculateBackoffDelay(1) // 15000 (15s)
|
||||
* HTTPFetcher.calculateBackoffDelay(2) // 30000 (30s)
|
||||
* HTTPFetcher.calculateBackoffDelay(3) // 60000 (60s)
|
||||
* HTTPFetcher.calculateBackoffDelay(6) // 300000 (5min, capped)
|
||||
*/
|
||||
static calculateBackoffDelay (attempt, { baseDelay = 15000, maxDelay = 300000 } = {}) {
|
||||
return Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HTTPFetcher instance
|
||||
* @param {string} url - The URL to fetch
|
||||
* @param {object} options - Configuration options
|
||||
* @param {number} [options.reloadInterval] - Time in ms between fetches (default: 5 min)
|
||||
* @param {object} [options.auth] - Authentication options
|
||||
* @param {string} [options.auth.method] - 'basic' or 'bearer'
|
||||
* @param {string} [options.auth.user] - Username for basic auth
|
||||
* @param {string} [options.auth.pass] - Password or token
|
||||
* @param {boolean} [options.selfSignedCert] - Accept self-signed certificates
|
||||
* @param {object} [options.headers] - Additional headers to send
|
||||
* @param {number} [options.maxRetries] - Max retries for 5xx errors (default: 3)
|
||||
* @param {number} [options.timeout] - Request timeout in ms (default: 30000)
|
||||
* @param {string} [options.logContext] - Optional context for log messages (e.g., provider name)
|
||||
*/
|
||||
constructor (url, options = {}) {
|
||||
super();
|
||||
|
||||
this.url = url;
|
||||
this.reloadInterval = options.reloadInterval || 5 * 60 * 1000;
|
||||
this.auth = options.auth || null;
|
||||
this.selfSignedCert = options.selfSignedCert || false;
|
||||
this.customHeaders = options.headers || {};
|
||||
this.maxRetries = options.maxRetries || MAX_SERVER_BACKOFF;
|
||||
this.timeout = options.timeout || DEFAULT_TIMEOUT;
|
||||
this.logContext = options.logContext ? `[${options.logContext}] ` : "";
|
||||
|
||||
this.reloadTimer = null;
|
||||
this.serverErrorCount = 0;
|
||||
this.networkErrorCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending reload timer
|
||||
*/
|
||||
clearTimer () {
|
||||
if (this.reloadTimer) {
|
||||
clearTimeout(this.reloadTimer);
|
||||
this.reloadTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next fetch.
|
||||
* If no delay is provided, uses reloadInterval.
|
||||
* If delay is provided but very short (< 1 second), clamps to reloadInterval
|
||||
* to prevent hammering servers.
|
||||
* @param {number} [delay] - Delay in milliseconds
|
||||
*/
|
||||
scheduleNextFetch (delay) {
|
||||
let nextDelay = delay ?? this.reloadInterval;
|
||||
|
||||
// Only clamp if delay is unreasonably short (< 1 second)
|
||||
// This allows respecting Retry-After headers while preventing abuse
|
||||
if (nextDelay < 1000) {
|
||||
nextDelay = this.reloadInterval;
|
||||
}
|
||||
|
||||
// Don't schedule in test mode
|
||||
if (process.env.mmTestMode === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reloadTimer = setTimeout(() => this.fetch(), nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts periodic fetching
|
||||
*/
|
||||
startPeriodicFetch () {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the options object for fetch
|
||||
* @returns {object} Options object containing headers (and dispatcher if needed)
|
||||
*/
|
||||
getRequestOptions () {
|
||||
const headers = {
|
||||
"User-Agent": getUserAgent(),
|
||||
...this.customHeaders
|
||||
};
|
||||
const options = { headers };
|
||||
|
||||
if (this.selfSignedCert) {
|
||||
options.dispatcher = new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.auth) {
|
||||
if (this.auth.method === "bearer") {
|
||||
headers.Authorization = `Bearer ${this.auth.pass}`;
|
||||
} else {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Retry-After header value
|
||||
* @param {string} retryAfter - The Retry-After header value
|
||||
* @returns {number|null} Milliseconds to wait or null if parsing failed
|
||||
*/
|
||||
#parseRetryAfter (retryAfter) {
|
||||
// Try parsing as seconds
|
||||
const seconds = Number(retryAfter);
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
// Try parsing as HTTP-date
|
||||
const retryDate = Date.parse(retryAfter);
|
||||
if (!Number.isNaN(retryDate)) {
|
||||
return Math.max(0, retryDate - Date.now());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the retry delay for a non-ok response
|
||||
* @param {Response} response - The fetch Response object
|
||||
* @returns {{delay: number, errorInfo: object}} Computed retry delay and error info
|
||||
*/
|
||||
#getDelayForResponse (response) {
|
||||
const { status } = response;
|
||||
let delay = this.reloadInterval;
|
||||
let message;
|
||||
let errorType = "UNKNOWN_ERROR";
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
errorType = "AUTH_FAILURE";
|
||||
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
|
||||
message = `Authentication failed (${status}). Check your API key. Waiting ${Math.round(delay / 60000)} minutes before retry.`;
|
||||
Log.error(`${this.logContext}${this.url} - ${message}`);
|
||||
} else if (status === 429) {
|
||||
errorType = "RATE_LIMITED";
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const parsed = retryAfter ? this.#parseRetryAfter(retryAfter) : null;
|
||||
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
message = `Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`;
|
||||
Log.warn(`${this.logContext}${this.url} - ${message}`);
|
||||
} else if (status >= 500) {
|
||||
errorType = "SERVER_ERROR";
|
||||
this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries);
|
||||
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
|
||||
message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`;
|
||||
Log.error(`${this.logContext}${this.url} - ${message}`);
|
||||
} else if (status >= 400) {
|
||||
errorType = "CLIENT_ERROR";
|
||||
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
message = `Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`;
|
||||
Log.error(`${this.logContext}${this.url} - ${message}`);
|
||||
} else {
|
||||
message = `Unexpected HTTP status ${status}.`;
|
||||
Log.error(`${this.logContext}${this.url} - ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
delay,
|
||||
errorInfo: this.#createErrorInfo(message, status, errorType, delay)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error info object
|
||||
* @param {string} message - Error message
|
||||
* @param {number|null} status - HTTP status code or null for network errors
|
||||
* @param {string} errorType - Error type: AUTH_FAILURE, RATE_LIMITED, SERVER_ERROR, CLIENT_ERROR, NETWORK_ERROR
|
||||
* @param {number} retryAfter - Delay until next retry in ms
|
||||
* @param {Error} [originalError] - The original error if any
|
||||
* @returns {object} Error info object with translationKey for direct use
|
||||
*/
|
||||
#createErrorInfo (message, status, errorType, retryAfter, originalError = null) {
|
||||
return {
|
||||
message,
|
||||
status,
|
||||
errorType,
|
||||
translationKey: ERROR_TYPE_TO_TRANSLATION[errorType] || "MODULE_ERROR_UNSPECIFIED",
|
||||
retryAfter,
|
||||
retryCount: errorType === "NETWORK_ERROR" ? this.networkErrorCount : this.serverErrorCount,
|
||||
url: this.url,
|
||||
originalError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the HTTP fetch and emits appropriate events
|
||||
* @fires HTTPFetcher#response
|
||||
* @fires HTTPFetcher#error
|
||||
*/
|
||||
async fetch () {
|
||||
this.clearTimer();
|
||||
|
||||
let nextDelay = this.reloadInterval;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.url, {
|
||||
...this.getRequestOptions(),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { delay, errorInfo } = this.#getDelayForResponse(response);
|
||||
nextDelay = delay;
|
||||
this.emit("error", errorInfo);
|
||||
} else {
|
||||
// Reset error counts on success
|
||||
this.serverErrorCount = 0;
|
||||
this.networkErrorCount = 0;
|
||||
|
||||
/**
|
||||
* Response event - fired when fetch succeeds
|
||||
* @event HTTPFetcher#response
|
||||
* @type {Response}
|
||||
*/
|
||||
this.emit("response", response);
|
||||
}
|
||||
} catch (error) {
|
||||
const isTimeout = error.name === "AbortError";
|
||||
const message = isTimeout ? `Request timeout after ${this.timeout}ms` : `Network error: ${error.message}`;
|
||||
|
||||
// Apply exponential backoff for network errors
|
||||
this.networkErrorCount = Math.min(this.networkErrorCount + 1, this.maxRetries);
|
||||
const backoffDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, {
|
||||
maxDelay: this.reloadInterval
|
||||
});
|
||||
nextDelay = backoffDelay;
|
||||
|
||||
// Truncate URL for cleaner logs
|
||||
let shortUrl = this.url;
|
||||
try {
|
||||
const urlObj = new URL(this.url);
|
||||
shortUrl = `${urlObj.origin}${urlObj.pathname}${urlObj.search.length > 50 ? "?..." : urlObj.search}`;
|
||||
} catch (urlError) {
|
||||
// If URL parsing fails, use original URL
|
||||
}
|
||||
|
||||
// Gradual log-level escalation: WARN for first 2 attempts, ERROR after
|
||||
const retryMessage = `Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`;
|
||||
if (this.networkErrorCount <= 2) {
|
||||
Log.warn(`${this.logContext}${shortUrl} - ${message} ${retryMessage}`);
|
||||
} else {
|
||||
Log.error(`${this.logContext}${shortUrl} - ${message} ${retryMessage}`);
|
||||
}
|
||||
|
||||
const errorInfo = this.#createErrorInfo(
|
||||
message,
|
||||
null,
|
||||
"NETWORK_ERROR",
|
||||
nextDelay,
|
||||
error
|
||||
);
|
||||
|
||||
/**
|
||||
* Error event - fired when fetch fails
|
||||
* @event HTTPFetcher#error
|
||||
* @type {object}
|
||||
* @property {string} message - Error description
|
||||
* @property {number|null} statusCode - HTTP status or null for network errors
|
||||
* @property {number} retryDelay - Ms until next retry
|
||||
* @property {number} retryCount - Number of consecutive server errors
|
||||
* @property {string} url - The URL that was fetched
|
||||
* @property {Error|null} originalError - The original error
|
||||
*/
|
||||
this.emit("error", errorInfo);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
this.scheduleNextFetch(nextDelay);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HTTPFetcher;
|
||||
17
js/loader.js
@@ -17,8 +17,7 @@ const Loader = (function () {
|
||||
const getEnvVarsFromConfig = function () {
|
||||
return {
|
||||
modulesDir: config.foreignModulesDir || "modules",
|
||||
defaultModulesDir: config.defaultModulesDir || "defaultmodules",
|
||||
customCss: config.customCss || "config/custom.css"
|
||||
customCss: config.customCss || "css/custom.css"
|
||||
};
|
||||
};
|
||||
|
||||
@@ -104,7 +103,7 @@ const Loader = (function () {
|
||||
let moduleFolder = `${envVars.modulesDir}/${module}`;
|
||||
|
||||
if (defaultModules.indexOf(moduleName) !== -1) {
|
||||
const defaultModuleFolder = `${envVars.defaultModulesDir}/${module}`;
|
||||
const defaultModuleFolder = `modules/default/${module}`;
|
||||
if (window.name !== "jsdom") {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
} else {
|
||||
@@ -193,7 +192,7 @@ const Loader = (function () {
|
||||
* @param {string} fileName Path of the file we want to load.
|
||||
* @returns {Promise} resolved when the file is loaded
|
||||
*/
|
||||
const loadFile = function (fileName) {
|
||||
const loadFile = async function (fileName) {
|
||||
const extension = fileName.slice((Math.max(0, fileName.lastIndexOf(".")) || Infinity) + 1);
|
||||
let script, stylesheet;
|
||||
|
||||
@@ -262,15 +261,15 @@ const Loader = (function () {
|
||||
|
||||
/**
|
||||
* Load a file (script or stylesheet).
|
||||
* Prevent double loading and search for files defined in js/vendor.js.
|
||||
* Prevent double loading and search for files in the vendor folder.
|
||||
* @param {string} fileName Path of the file we want to load.
|
||||
* @param {Module} module The module that calls the loadFile function.
|
||||
* @returns {Promise} resolved when the file is loaded
|
||||
*/
|
||||
loadFileForModule (fileName, module) {
|
||||
async loadFileForModule (fileName, module) {
|
||||
if (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) {
|
||||
Log.log(`File already loaded: ${fileName}`);
|
||||
return Promise.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileName.indexOf("http://") === 0 || fileName.indexOf("https://") === 0 || fileName.indexOf("/") !== -1) {
|
||||
@@ -281,8 +280,8 @@ const Loader = (function () {
|
||||
}
|
||||
|
||||
if (vendor[fileName] !== undefined) {
|
||||
// This file is defined in js/vendor.js.
|
||||
// Load it from its location.
|
||||
// This file is available in the vendor folder.
|
||||
// Load it from this vendor folder.
|
||||
loadedFiles.push(fileName.toLowerCase());
|
||||
return loadFile(`${vendor[fileName]}`);
|
||||
}
|
||||
|
||||
229
js/logger.js
@@ -1,114 +1,131 @@
|
||||
// Logger for MagicMirror² — works both in Node.js (CommonJS) and the browser (global).
|
||||
(function () {
|
||||
if (typeof module !== "undefined") {
|
||||
// This logger is very simple, but needs to be extended.
|
||||
(function (root, factory) {
|
||||
if (typeof exports === "object") {
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
const { styleText } = require("node:util");
|
||||
|
||||
const LABEL_COLORS = { error: "red", warn: "yellow", debug: "bgBlue", info: "blue" };
|
||||
const MSG_COLORS = { error: "red", warn: "yellow", info: "blue" };
|
||||
|
||||
const formatTimestamp = () => {
|
||||
const d = new Date();
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
const pad3 = (n) => String(n).padStart(3, "0");
|
||||
const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
|
||||
return `[${date} ${time}]`;
|
||||
};
|
||||
|
||||
const getCallerPrefix = () => {
|
||||
try {
|
||||
const lines = new Error().stack.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.includes("node:") || line.includes("js/logger.js") || line.includes("node_modules")) continue;
|
||||
const match = line.match(/\((.+?\.js):\d+:\d+\)/) || line.match(/at\s+(.+?\.js):\d+:\d+/);
|
||||
if (match) {
|
||||
const file = match[1];
|
||||
const baseName = file.replace(/.*\/(.*)\.js/, "$1");
|
||||
const parentDir = file.replace(/.*\/(.*)\/.*\.js/, "$1");
|
||||
return styleText("gray", parentDir === "js" ? `[${baseName}]` : `[${parentDir}]`);
|
||||
// add timestamps in front of log messages
|
||||
require("console-stamp")(console, {
|
||||
format: ":date(yyyy-mm-dd HH:MM:ss.l) :label(7) :pre() :msg",
|
||||
tokens: {
|
||||
pre: () => {
|
||||
const err = new Error();
|
||||
Error.prepareStackTrace = (_, stack) => stack;
|
||||
const stack = err.stack;
|
||||
Error.prepareStackTrace = undefined;
|
||||
try {
|
||||
for (const line of stack) {
|
||||
const file = line.getFileName();
|
||||
if (file && !file.includes("node:") && !file.includes("js/logger.js") && !file.includes("node_modules")) {
|
||||
const filename = file.replace(/.*\/(.*).js/, "$1");
|
||||
const filepath = file.replace(/.*\/(.*)\/.*.js/, "$1");
|
||||
if (filepath === "js") {
|
||||
return styleText("grey", `[${filename}]`);
|
||||
} else {
|
||||
return styleText("grey", `[${filepath}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return styleText("grey", "[unknown]");
|
||||
}
|
||||
},
|
||||
label: (arg) => {
|
||||
const { method, defaultTokens } = arg;
|
||||
let label = defaultTokens.label(arg);
|
||||
switch (method) {
|
||||
case "error":
|
||||
label = styleText("red", label);
|
||||
break;
|
||||
case "warn":
|
||||
label = styleText("yellow", label);
|
||||
break;
|
||||
case "debug":
|
||||
label = styleText("bgBlue", label);
|
||||
break;
|
||||
case "info":
|
||||
label = styleText("blue", label);
|
||||
break;
|
||||
}
|
||||
return label;
|
||||
},
|
||||
msg: (arg) => {
|
||||
const { method, defaultTokens } = arg;
|
||||
let msg = defaultTokens.msg(arg);
|
||||
switch (method) {
|
||||
case "error":
|
||||
msg = styleText("red", msg);
|
||||
break;
|
||||
case "warn":
|
||||
msg = styleText("yellow", msg);
|
||||
break;
|
||||
case "info":
|
||||
msg = styleText("blue", msg);
|
||||
break;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
return styleText("gray", "[unknown]");
|
||||
};
|
||||
|
||||
// Patch console methods to prepend timestamp, level label, and caller prefix.
|
||||
for (const method of ["debug", "log", "info", "warn", "error"]) {
|
||||
const original = console[method].bind(console);
|
||||
const labelRaw = `[${method.toUpperCase()}]`.padEnd(7);
|
||||
const label = LABEL_COLORS[method] ? styleText(LABEL_COLORS[method], labelRaw) : labelRaw;
|
||||
console[method] = (...args) => {
|
||||
const prefix = `${formatTimestamp()} ${label} ${getCallerPrefix()}`;
|
||||
const msgColor = MSG_COLORS[method];
|
||||
if (msgColor && args.length > 0 && typeof args[0] === "string") {
|
||||
original(prefix, styleText(msgColor, args[0]), ...args.slice(1));
|
||||
} else {
|
||||
original(prefix, ...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// Node, CommonJS
|
||||
module.exports = makeLogger();
|
||||
} else {
|
||||
// Browser globals
|
||||
window.Log = makeLogger();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the logger object. Logging is disabled when running in test mode
|
||||
* (Node.js) or inside jsdom (browser).
|
||||
* @returns {object} The logger object with log level methods.
|
||||
*/
|
||||
function makeLogger () {
|
||||
const enableLog = typeof module !== "undefined"
|
||||
? process.env.mmTestMode !== "true"
|
||||
: typeof window === "object" && window.name !== "jsdom";
|
||||
|
||||
let logLevel;
|
||||
|
||||
if (enableLog) {
|
||||
logLevel = {
|
||||
debug: console.debug.bind(console),
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
group: console.group.bind(console),
|
||||
groupCollapsed: console.groupCollapsed.bind(console),
|
||||
groupEnd: console.groupEnd.bind(console),
|
||||
time: console.time.bind(console),
|
||||
timeEnd: console.timeEnd.bind(console),
|
||||
timeStamp: console.timeStamp.bind(console)
|
||||
};
|
||||
|
||||
// Only these methods are affected by setLogLevel.
|
||||
// Utility methods (group, time, etc.) are always active.
|
||||
logLevel.setLogLevel = function (newLevel) {
|
||||
for (const key of ["debug", "log", "info", "warn", "error"]) {
|
||||
const disabled = newLevel && !newLevel.includes(key.toUpperCase());
|
||||
logLevel[key] = disabled ? function () {} : console[key].bind(console);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
logLevel = {
|
||||
debug () {},
|
||||
log () {},
|
||||
info () {},
|
||||
warn () {},
|
||||
error () {},
|
||||
group () {},
|
||||
groupCollapsed () {},
|
||||
groupEnd () {},
|
||||
time () {},
|
||||
timeEnd () {},
|
||||
timeStamp () {}
|
||||
};
|
||||
|
||||
logLevel.setLogLevel = function () {};
|
||||
});
|
||||
}
|
||||
|
||||
return logLevel;
|
||||
// Node, CommonJS-like
|
||||
module.exports = factory(root.config);
|
||||
} else {
|
||||
// Browser globals (root is window)
|
||||
root.Log = factory(root.config);
|
||||
}
|
||||
}());
|
||||
}(this, function (config) {
|
||||
let logLevel;
|
||||
let enableLog;
|
||||
if (typeof exports === "object") {
|
||||
// in nodejs and not running in test mode
|
||||
enableLog = process.env.mmTestMode !== "true";
|
||||
} else {
|
||||
// in browser and not running with jsdom
|
||||
enableLog = typeof window === "object" && window.name !== "jsdom";
|
||||
}
|
||||
|
||||
if (enableLog) {
|
||||
logLevel = {
|
||||
debug: Function.prototype.bind.call(console.debug, console),
|
||||
log: Function.prototype.bind.call(console.log, console),
|
||||
info: Function.prototype.bind.call(console.info, console),
|
||||
warn: Function.prototype.bind.call(console.warn, console),
|
||||
error: Function.prototype.bind.call(console.error, console),
|
||||
group: Function.prototype.bind.call(console.group, console),
|
||||
groupCollapsed: Function.prototype.bind.call(console.groupCollapsed, console),
|
||||
groupEnd: Function.prototype.bind.call(console.groupEnd, console),
|
||||
time: Function.prototype.bind.call(console.time, console),
|
||||
timeEnd: Function.prototype.bind.call(console.timeEnd, console),
|
||||
timeStamp: console.timeStamp ? Function.prototype.bind.call(console.timeStamp, console) : function () {}
|
||||
};
|
||||
|
||||
logLevel.setLogLevel = function (newLevel) {
|
||||
if (newLevel) {
|
||||
Object.keys(logLevel).forEach(function (key) {
|
||||
if (!newLevel.includes(key.toLocaleUpperCase())) {
|
||||
logLevel[key] = function () {};
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
} else {
|
||||
logLevel = {
|
||||
debug () {},
|
||||
log () {},
|
||||
info () {},
|
||||
warn () {},
|
||||
error () {},
|
||||
group () {},
|
||||
groupCollapsed () {},
|
||||
groupEnd () {},
|
||||
time () {},
|
||||
timeEnd () {},
|
||||
timeStamp () {}
|
||||
};
|
||||
|
||||
logLevel.setLogLevel = function () {};
|
||||
}
|
||||
|
||||
return logLevel;
|
||||
}));
|
||||
|
||||
20
js/main.js
@@ -1,4 +1,4 @@
|
||||
/* global Loader, defaults, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */
|
||||
/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */
|
||||
|
||||
const MM = (function () {
|
||||
let modules = [];
|
||||
@@ -470,15 +470,17 @@ const MM = (function () {
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the core config from the server (already combined with the system defaults).
|
||||
* Loads the core config and combines it with the system defaults.
|
||||
*/
|
||||
const loadConfig = async function () {
|
||||
try {
|
||||
const res = await fetch(new URL("config/", `${location.origin}${config.basePath}`));
|
||||
config = JSON.parse(await res.text());
|
||||
} catch (error) {
|
||||
Log.error("Unable to retrieve config", error);
|
||||
const loadConfig = function () {
|
||||
// FIXME: Think about how to pass config around without breaking tests
|
||||
if (typeof config === "undefined") {
|
||||
config = defaults;
|
||||
Log.error("Config file is missing! Please create a config file.");
|
||||
return;
|
||||
}
|
||||
|
||||
config = Object.assign({}, defaults, config);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -580,7 +582,7 @@ const MM = (function () {
|
||||
*/
|
||||
async init () {
|
||||
Log.info("Initializing MagicMirror².");
|
||||
await loadConfig();
|
||||
loadConfig();
|
||||
|
||||
Log.setLogLevel(config.logLevel);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* global Class, cloneObject, Loader, MMSocket, nunjucks */
|
||||
/* global Class, cloneObject, Loader, MMSocket, nunjucks, Translator */
|
||||
|
||||
/*
|
||||
* Module Blueprint.
|
||||
@@ -43,7 +43,7 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Called when the module is started.
|
||||
*/
|
||||
start () {
|
||||
async start () {
|
||||
Log.info(`Starting module: ${this.name}`);
|
||||
},
|
||||
|
||||
|
||||
18
js/module_functions.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Schedule the timer for the next update
|
||||
* @param {object} timer The timer of the module
|
||||
* @param {bigint} intervalMS interval in milliseconds
|
||||
* @param {Promise} callback function to call when the timer expires
|
||||
*/
|
||||
const scheduleTimer = function (timer, intervalMS, callback) {
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
// only set timer when not running in test mode
|
||||
let tmr = timer;
|
||||
clearTimeout(tmr);
|
||||
tmr = setTimeout(function () {
|
||||
callback();
|
||||
}, intervalMS);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { scheduleTimer };
|
||||
@@ -1,7 +1,6 @@
|
||||
const express = require("express");
|
||||
const Log = require("logger");
|
||||
const Class = require("./class");
|
||||
const { replaceSecretPlaceholder } = require("#server_functions");
|
||||
|
||||
const NodeHelper = Class.extend({
|
||||
init () {
|
||||
@@ -28,7 +27,7 @@ const NodeHelper = Class.extend({
|
||||
/**
|
||||
* This method is called when a socket notification arrives.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
socketNotificationReceived (notification, payload) {
|
||||
Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);
|
||||
@@ -89,17 +88,7 @@ const NodeHelper = Class.extend({
|
||||
io.of(this.name).on("connection", (socket) => {
|
||||
// register catch all.
|
||||
socket.onAny((notification, payload) => {
|
||||
if (config.hideConfigSecrets && payload && typeof payload === "object") {
|
||||
try {
|
||||
const payloadStr = replaceSecretPlaceholder(JSON.stringify(payload));
|
||||
this.socketNotificationReceived(notification, JSON.parse(payloadStr));
|
||||
} catch (e) {
|
||||
Log.error("Error substituting variables in payload: ", e);
|
||||
this.socketNotificationReceived(notification, payload);
|
||||
}
|
||||
} else {
|
||||
this.socketNotificationReceived(notification, payload);
|
||||
}
|
||||
this.socketNotificationReceived(notification, payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
34
js/server.js
@@ -6,20 +6,18 @@ const express = require("express");
|
||||
const helmet = require("helmet");
|
||||
const socketio = require("socket.io");
|
||||
const Log = require("logger");
|
||||
const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions");
|
||||
|
||||
const { ipAccessControl } = require("./ip_access_control");
|
||||
const { ipAccessControl } = require(`${__dirname}/ip_access_control`);
|
||||
|
||||
const vendor = require("./vendor");
|
||||
|
||||
const { getHtml, getVersion, getEnvVars, cors } = require("#server_functions");
|
||||
const vendor = require(`${__dirname}/vendor`);
|
||||
|
||||
/**
|
||||
* Server
|
||||
* @param {object} configObj The MM config full and redacted
|
||||
* @param {object} config The MM config
|
||||
* @class
|
||||
*/
|
||||
function Server (configObj) {
|
||||
const config = configObj.fullConf;
|
||||
function Server (config) {
|
||||
const app = express();
|
||||
const port = process.env.MM_PORT || config.port;
|
||||
const serverSockets = new Set();
|
||||
@@ -91,13 +89,7 @@ function Server (configObj) {
|
||||
app.use(helmet(config.httpHeaders));
|
||||
app.use("/js", express.static(__dirname));
|
||||
|
||||
if (config.hideConfigSecrets) {
|
||||
app.get("/config/config.env", (req, res) => {
|
||||
res.status(404).send("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /config/config.env</pre>\n</body>\n</html>");
|
||||
});
|
||||
}
|
||||
|
||||
let directories = ["/config", "/css", "/favicon.svg", "/defaultmodules", "/modules", "/node_modules/animate.css", "/node_modules/@fontsource", "/node_modules/@fortawesome", "/translations", "/tests/configs", "/tests/mocks"];
|
||||
let directories = ["/config", "/css", "/modules", "/node_modules/animate.css", "/node_modules/@fontsource", "/node_modules/@fortawesome", "/translations", "/tests/configs", "/tests/mocks"];
|
||||
for (const [key, value] of Object.entries(vendor)) {
|
||||
const dirArr = value.split("/");
|
||||
if (dirArr[0] === "node_modules") directories.push(`/${dirArr[0]}/${dirArr[1]}`);
|
||||
@@ -107,22 +99,12 @@ function Server (configObj) {
|
||||
app.use(directory, express.static(path.resolve(global.root_path + directory)));
|
||||
}
|
||||
|
||||
const startUp = new Date();
|
||||
const getStartup = (req, res) => res.send(startUp);
|
||||
|
||||
const getConfig = (req, res) => {
|
||||
if (config.hideConfigSecrets) {
|
||||
res.send(configObj.redactedConf);
|
||||
} else {
|
||||
res.send(configObj.fullConf);
|
||||
}
|
||||
};
|
||||
app.get("/config", (req, res) => getConfig(req, res));
|
||||
|
||||
app.get("/cors", async (req, res) => await cors(req, res));
|
||||
|
||||
app.get("/version", (req, res) => getVersion(req, res));
|
||||
|
||||
app.get("/config", (req, res) => getConfig(req, res));
|
||||
|
||||
app.get("/startup", (req, res) => getStartup(req, res));
|
||||
|
||||
app.get("/env", (req, res) => getEnvVars(req, res));
|
||||
|
||||
@@ -4,6 +4,15 @@ const Log = require("logger");
|
||||
|
||||
const startUp = new Date();
|
||||
|
||||
/**
|
||||
* Gets the config.
|
||||
* @param {Request} req - the request
|
||||
* @param {Response} res - the result
|
||||
*/
|
||||
function getConfig (req, res) {
|
||||
res.send(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the startup time.
|
||||
* @param {Request} req - the request
|
||||
@@ -13,17 +22,6 @@ function getStartup (req, res) {
|
||||
res.send(startUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* A method that replaces the secret placeholders `**SECRET_ABC**` with the environment variable SECRET_ABC
|
||||
* @param {string} input - the input string
|
||||
* @returns {string} the input with real variable content
|
||||
*/
|
||||
function replaceSecretPlaceholder (input) {
|
||||
return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (match, group) => {
|
||||
return process.env[group];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A method that forwards HTTP Get-methods to the internet to avoid CORS-errors.
|
||||
*
|
||||
@@ -46,11 +44,6 @@ async function cors (req, res) {
|
||||
return res.status(400).send(url);
|
||||
} else {
|
||||
url = match[1];
|
||||
if (typeof config !== "undefined") {
|
||||
if (config.hideConfigSecrets) {
|
||||
url = replaceSecretPlaceholder(url);
|
||||
}
|
||||
}
|
||||
|
||||
const headersToSend = getHeadersToSend(req.url);
|
||||
const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);
|
||||
@@ -62,8 +55,8 @@ async function cors (req, res) {
|
||||
const headerValue = response.headers.get(header);
|
||||
if (header) res.set(header, headerValue);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
res.send(Buffer.from(arrayBuffer));
|
||||
const data = await response.text();
|
||||
res.send(data);
|
||||
} else {
|
||||
throw new Error(`Response status: ${response.status}`);
|
||||
}
|
||||
@@ -125,6 +118,12 @@ function getHtml (req, res) {
|
||||
html = html.replace("#VERSION#", global.version);
|
||||
html = html.replace("#TESTMODE#", global.mmTestMode);
|
||||
|
||||
let configFile = "config/config.js";
|
||||
if (typeof global.configuration_file !== "undefined") {
|
||||
configFile = global.configuration_file;
|
||||
}
|
||||
html = html.replace("#CONFIG_FILE#", configFile);
|
||||
|
||||
res.send(html);
|
||||
}
|
||||
|
||||
@@ -163,7 +162,7 @@ function getUserAgent () {
|
||||
* @returns {object} environment variables key: values
|
||||
*/
|
||||
function getEnvVarsAsObj () {
|
||||
const obj = { modulesDir: `${config.foreignModulesDir}`, defaultModulesDir: `${config.defaultModulesDir}`, customCss: `${config.customCss}` };
|
||||
const obj = { modulesDir: `${config.foreignModulesDir}`, customCss: `${config.customCss}` };
|
||||
if (process.env.MM_MODULES_DIR) {
|
||||
obj.modulesDir = process.env.MM_MODULES_DIR.replace(`${global.root_path}/`, "");
|
||||
}
|
||||
@@ -202,4 +201,4 @@ function getConfigFilePath () {
|
||||
return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);
|
||||
}
|
||||
|
||||
module.exports = { cors, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath, replaceSecretPlaceholder };
|
||||
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath };
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
const os = require("node:os");
|
||||
const si = require("systeminformation");
|
||||
// needed with relative path because logSystemInformation is called in an own process in app.js:
|
||||
const mmVersion = require("../package").version;
|
||||
const Log = require("./logger");
|
||||
|
||||
const logSystemInformation = async () => {
|
||||
try {
|
||||
const system = await si.system();
|
||||
const osInfo = await si.osInfo();
|
||||
const versions = await si.versions();
|
||||
|
||||
const usedNodeVersion = process.version.replace("v", "");
|
||||
const installedNodeVersion = versions.node;
|
||||
const totalRam = (os.totalmem() / 1024 / 1024).toFixed(2);
|
||||
const freeRam = (os.freemem() / 1024 / 1024).toFixed(2);
|
||||
const usedRam = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(2);
|
||||
|
||||
let systemDataString = [
|
||||
"\n#### System Information ####",
|
||||
`- SYSTEM: manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}; MM: v${mmVersion}`,
|
||||
`- OS: platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`,
|
||||
`- VERSIONS: electron: ${process.env.ELECTRON_VERSION}; used node: ${usedNodeVersion}; installed node: ${installedNodeVersion}; npm: ${versions.npm}; pm2: ${versions.pm2}`,
|
||||
`- ENV: XDG_SESSION_TYPE: ${process.env.XDG_SESSION_TYPE}; MM_CONFIG_FILE: ${process.env.MM_CONFIG_FILE}`,
|
||||
` WAYLAND_DISPLAY: ${process.env.WAYLAND_DISPLAY}; DISPLAY: ${process.env.DISPLAY}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`,
|
||||
`- RAM: total: ${totalRam} MB; free: ${freeRam} MB; used: ${usedRam} MB`,
|
||||
`- OTHERS: uptime: ${Math.floor(os.uptime() / 60)} minutes; timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`
|
||||
].join("\n");
|
||||
Log.info(systemDataString);
|
||||
|
||||
// Return is currently only for tests
|
||||
return systemDataString;
|
||||
} catch (error) {
|
||||
Log.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = logSystemInformation;
|
||||
logSystemInformation();
|
||||
331
js/utils.js
@@ -1,293 +1,84 @@
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const { loadEnvFile } = require("node:process");
|
||||
const si = require("systeminformation");
|
||||
const Log = require("logger");
|
||||
|
||||
const modulePositions = []; // will get list from index.html
|
||||
const regionRegEx = /"region ([^"]*)/i;
|
||||
const indexFileName = "index.html";
|
||||
const discoveredPositionsJSFilename = "js/positions.js";
|
||||
|
||||
const { styleText } = require("node:util");
|
||||
const Log = require("logger");
|
||||
const Ajv = require("ajv");
|
||||
const globals = require("globals");
|
||||
const { Linter } = require("eslint");
|
||||
const { getConfigFilePath } = require("#server_functions");
|
||||
module.exports = {
|
||||
|
||||
const linter = new Linter({ configType: "flat" });
|
||||
const ajv = new Ajv();
|
||||
|
||||
const requireFromString = (src) => {
|
||||
const m = new module.constructor();
|
||||
m._compile(src, "");
|
||||
return m.exports;
|
||||
};
|
||||
|
||||
// return all available module positions
|
||||
const getAvailableModulePositions = () => {
|
||||
return modulePositions;
|
||||
};
|
||||
|
||||
// return if position is on modulePositions Array (true/false)
|
||||
const moduleHasValidPosition = (position) => {
|
||||
if (getAvailableModulePositions().indexOf(position) === -1) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getModulePositions = () => {
|
||||
// if not already discovered
|
||||
if (modulePositions.length === 0) {
|
||||
// get the lines of the index.html
|
||||
const lines = fs.readFileSync(indexFileName).toString().split("\n");
|
||||
// loop thru the lines
|
||||
lines.forEach((line) => {
|
||||
// run the regex on each line
|
||||
const results = regionRegEx.exec(line);
|
||||
// if the regex returned something
|
||||
if (results && results.length > 0) {
|
||||
// get the position parts and replace space with underscore
|
||||
const positionName = results[1].replace(" ", "_");
|
||||
// add it to the list only if not already present (avoid duplicates)
|
||||
if (!modulePositions.includes(positionName)) {
|
||||
modulePositions.push(positionName);
|
||||
}
|
||||
}
|
||||
});
|
||||
async logSystemInformation (mirrorVersion) {
|
||||
try {
|
||||
fs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`);
|
||||
}
|
||||
catch (error) {
|
||||
Log.error("unable to write js/positions.js with the discovered module positions\nmake the MagicMirror/js folder writeable by the user starting MagicMirror");
|
||||
}
|
||||
}
|
||||
// return the list to the caller
|
||||
return modulePositions;
|
||||
};
|
||||
const system = await si.system();
|
||||
const osInfo = await si.osInfo();
|
||||
const versions = await si.versions();
|
||||
|
||||
/**
|
||||
* Checks the config for deprecated options and throws a warning in the logs
|
||||
* if it encounters one option from the deprecated.js list
|
||||
* @param {object} userConfig The user config
|
||||
*/
|
||||
const checkDeprecatedOptions = (userConfig) => {
|
||||
const deprecated = require(`${global.root_path}/js/deprecated`);
|
||||
const usedNodeVersion = process.version.replace("v", "");
|
||||
const installedNodeVersion = versions.node;
|
||||
const totalRam = (os.totalmem() / 1024 / 1024).toFixed(2);
|
||||
const freeRam = (os.freemem() / 1024 / 1024).toFixed(2);
|
||||
const usedRam = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(2);
|
||||
|
||||
// check for deprecated core options
|
||||
const deprecatedOptions = deprecated.configs;
|
||||
const usedDeprecated = deprecatedOptions.filter((option) => userConfig.hasOwnProperty(option));
|
||||
if (usedDeprecated.length > 0) {
|
||||
Log.warn(`WARNING! Your config is using deprecated option(s): ${usedDeprecated.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
|
||||
}
|
||||
let systemDataString = [
|
||||
"\n#### System Information ####",
|
||||
`- SYSTEM: manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}; MM: ${mirrorVersion}`,
|
||||
`- OS: platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`,
|
||||
`- VERSIONS: electron: ${process.versions.electron}; used node: ${usedNodeVersion}; installed node: ${installedNodeVersion}; npm: ${versions.npm}; pm2: ${versions.pm2}`,
|
||||
`- ENV: XDG_SESSION_TYPE: ${process.env.XDG_SESSION_TYPE}; MM_CONFIG_FILE: ${process.env.MM_CONFIG_FILE}`,
|
||||
` WAYLAND_DISPLAY: ${process.env.WAYLAND_DISPLAY}; DISPLAY: ${process.env.DISPLAY}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`,
|
||||
`- RAM: total: ${totalRam} MB; free: ${freeRam} MB; used: ${usedRam} MB`,
|
||||
`- OTHERS: uptime: ${Math.floor(os.uptime() / 60)} minutes; timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`
|
||||
].join("\n");
|
||||
Log.info(systemDataString);
|
||||
|
||||
// check for deprecated module options
|
||||
for (const element of userConfig.modules) {
|
||||
if (deprecated[element.module] !== undefined && element.config !== undefined) {
|
||||
const deprecatedModuleOptions = deprecated[element.module];
|
||||
const usedDeprecatedModuleOptions = deprecatedModuleOptions.filter((option) => element.config.hasOwnProperty(option));
|
||||
if (usedDeprecatedModuleOptions.length > 0) {
|
||||
Log.warn(`WARNING! Your config for module ${element.module} is using deprecated option(s): ${usedDeprecatedModuleOptions.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the config file. Combines it with the defaults and returns the config
|
||||
* @returns {object} an object holding full and redacted config
|
||||
*/
|
||||
const loadConfig = () => {
|
||||
Log.log("Loading config ...");
|
||||
const defaults = require("./defaults");
|
||||
if (global.mmTestMode) {
|
||||
// if we are running in test mode
|
||||
defaults.address = "0.0.0.0";
|
||||
}
|
||||
|
||||
// For this check proposed to TestSuite
|
||||
// https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8
|
||||
const configFilename = getConfigFilePath();
|
||||
let templateFile = `${configFilename}.template`;
|
||||
|
||||
// check if templateFile exists
|
||||
try {
|
||||
fs.accessSync(templateFile, fs.constants.F_OK);
|
||||
Log.warn("config.js.template files are deprecated and not used anymore. You can use variables inside config.js so copy the template file content into config.js if needed.");
|
||||
} catch (error) {
|
||||
// no action
|
||||
}
|
||||
|
||||
// check if config.env exists
|
||||
const configEnvFile = `${configFilename.substr(0, configFilename.lastIndexOf("."))}.env`;
|
||||
try {
|
||||
if (fs.existsSync(configEnvFile)) {
|
||||
// load content into process.env
|
||||
loadEnvFile(configEnvFile);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.log(`${configEnvFile} does not exist. ${error.message}`);
|
||||
}
|
||||
|
||||
// Load config.js and catch errors if not accessible
|
||||
try {
|
||||
let configContent = fs.readFileSync(configFilename, "utf-8");
|
||||
const hideConfigSecrets = configContent.match(/^\s*hideConfigSecrets: true.*$/m);
|
||||
let configContentFull = configContent;
|
||||
let configContentRedacted = hideConfigSecrets ? configContent : undefined;
|
||||
Object.keys(process.env).forEach((env) => {
|
||||
configContentFull = configContentFull.replaceAll(`\${${env}}`, process.env[env]);
|
||||
if (hideConfigSecrets) {
|
||||
if (env.startsWith("SECRET_")) {
|
||||
configContentRedacted = configContentRedacted.replaceAll(`"\${${env}}"`, `"**${env}**"`);
|
||||
configContentRedacted = configContentRedacted.replaceAll(`\${${env}}`, `**${env}**`);
|
||||
} else {
|
||||
configContentRedacted = configContentRedacted.replaceAll(`\${${env}}`, process.env[env]);
|
||||
}
|
||||
}
|
||||
});
|
||||
configContentRedacted = configContentRedacted ? configContentRedacted : configContentFull;
|
||||
const configObj = {
|
||||
configFilename: configFilename,
|
||||
configContentFull: configContentFull,
|
||||
configContentRedacted: configContentRedacted,
|
||||
redactedConf: Object.assign({}, defaults, requireFromString(configContentRedacted)),
|
||||
fullConf: Object.assign({}, defaults, requireFromString(configContentFull))
|
||||
};
|
||||
|
||||
if (Object.keys(configObj.fullConf).length === 0) {
|
||||
Log.error("WARNING! Config file appears empty, maybe missing module.exports last line?");
|
||||
}
|
||||
checkDeprecatedOptions(configObj.fullConf);
|
||||
|
||||
try {
|
||||
const cfg = `let config = { basePath: "${configObj.fullConf.basePath}"};`;
|
||||
fs.writeFileSync(`${global.root_path}/config/basepath.js`, cfg, "utf-8");
|
||||
// Return is currently only for tests
|
||||
return systemDataString;
|
||||
} catch (error) {
|
||||
Log.error(`Could not write config/basepath.js file: ${error.message}`);
|
||||
Log.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
return configObj;
|
||||
// return all available module positions
|
||||
getAvailableModulePositions () {
|
||||
return modulePositions;
|
||||
},
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
Log.error(`Could not find config file: ${configFilename}`);
|
||||
} else if (error.code === "EACCES") {
|
||||
Log.error(`No permission to read config file: ${configFilename}`);
|
||||
} else {
|
||||
Log.error(`Cannot access config file: ${configFilename}\n${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
// return if position is on modulePositions Array (true/false)
|
||||
moduleHasValidPosition (position) {
|
||||
if (this.getAvailableModulePositions().indexOf(position) === -1) return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks the config file using eslint.
|
||||
* @param {object} configObject the configuration object
|
||||
*/
|
||||
const checkConfigFile = (configObject) => {
|
||||
let configObj = configObject;
|
||||
if (!configObj) configObj = loadConfig();
|
||||
const configFileName = configObj.configFilename;
|
||||
|
||||
// Validate syntax of the configuration file.
|
||||
Log.info(`Checking config file ${configFileName} ...`);
|
||||
|
||||
// I'm not sure if all ever is utf-8
|
||||
const configFile = configObj.configContentFull;
|
||||
|
||||
const errors = linter.verify(
|
||||
configFile,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
getModulePositions () {
|
||||
// if not already discovered
|
||||
if (modulePositions.length === 0) {
|
||||
// get the lines of the index.html
|
||||
const lines = fs.readFileSync(indexFileName).toString().split("\n");
|
||||
// loop thru the lines
|
||||
lines.forEach((line) => {
|
||||
// run the regex on each line
|
||||
const results = regionRegEx.exec(line);
|
||||
// if the regex returned something
|
||||
if (results && results.length > 0) {
|
||||
// get the position parts and replace space with underscore
|
||||
const positionName = results[1].replace(" ", "_");
|
||||
// add it to the list only if not already present (avoid duplicates)
|
||||
if (!modulePositions.includes(positionName)) {
|
||||
modulePositions.push(positionName);
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-sparse-arrays": "error",
|
||||
"no-undef": "error"
|
||||
});
|
||||
try {
|
||||
fs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`);
|
||||
}
|
||||
},
|
||||
configFileName
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
Log.info(styleText("green", "Your configuration file doesn't contain syntax errors :)"));
|
||||
validateModulePositions(configObj.fullConf);
|
||||
} else {
|
||||
let errorMessage = "Your configuration file contains syntax errors :(";
|
||||
|
||||
for (const error of errors) {
|
||||
errorMessage += `\nLine ${error.line} column ${error.column}: ${error.message}`;
|
||||
}
|
||||
Log.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} data - The content of the configuration file to validate.
|
||||
*/
|
||||
const validateModulePositions = (data) => {
|
||||
Log.info("Checking modules structure configuration ...");
|
||||
|
||||
const positionList = getModulePositions();
|
||||
|
||||
// Make Ajv schema configuration of modules config
|
||||
// Only scan "module" and "position"
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
modules: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
module: {
|
||||
type: "string"
|
||||
},
|
||||
position: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["module"]
|
||||
}
|
||||
catch (error) {
|
||||
Log.error("unable to write js/positions.js with the discovered module positions\nmake the MagicMirror/js folder writeable by the user starting MagicMirror");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Scan all modules
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const valid = validate(data);
|
||||
if (valid) {
|
||||
Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)"));
|
||||
|
||||
// Check for unknown positions (warning only, not an error)
|
||||
if (data.modules) {
|
||||
for (const [index, module] of data.modules.entries()) {
|
||||
if (module.position && !positionList.includes(module.position)) {
|
||||
Log.warn(`Module ${index} ("${module.module}") uses unknown position: "${module.position}"`);
|
||||
Log.warn(`Known positions are: ${positionList.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const module = validate.errors[0].instancePath.split("/")[2];
|
||||
const position = validate.errors[0].instancePath.split("/")[3];
|
||||
let errorMessage = "This module configuration contains errors:";
|
||||
errorMessage += `\n${JSON.stringify(data.modules[module], null, 2)}`;
|
||||
if (position) {
|
||||
errorMessage += `\n${position}: ${validate.errors[0].message}`;
|
||||
errorMessage += `\n${JSON.stringify(validate.errors[0].params.allowedValues, null, 2).slice(1, -1)}`;
|
||||
} else {
|
||||
errorMessage += validate.errors[0].message;
|
||||
}
|
||||
Log.error(errorMessage);
|
||||
process.exit(1);
|
||||
// return the list to the caller
|
||||
return modulePositions;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { loadConfig, getModulePositions, moduleHasValidPosition, getAvailableModulePositions, checkConfigFile };
|
||||
|
||||
@@ -169,7 +169,9 @@ Module.register("calendar", {
|
||||
|
||||
notificationReceived (notification, payload, sender) {
|
||||
if (notification === "FETCH_CALENDAR") {
|
||||
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
|
||||
if (this.hasCalendarURL(payload.url)) {
|
||||
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -181,38 +183,40 @@ Module.register("calendar", {
|
||||
}
|
||||
|
||||
if (notification === "CALENDAR_EVENTS") {
|
||||
// have we received events for this url
|
||||
if (!this.calendarData[payload.url]) {
|
||||
// no, setup the structure to hold the info
|
||||
this.calendarData[payload.url] = { events: null, checksum: null };
|
||||
}
|
||||
// save the event list
|
||||
this.calendarData[payload.url].events = payload.events;
|
||||
|
||||
this.error = null;
|
||||
this.loaded = true;
|
||||
|
||||
if (this.config.broadcastEvents) {
|
||||
this.broadcastEvents();
|
||||
}
|
||||
// if the checksum is the same
|
||||
if (this.calendarData[payload.url].checksum === payload.checksum) {
|
||||
// then don't update the UI
|
||||
return;
|
||||
}
|
||||
// haven't seen or the checksum is different
|
||||
this.calendarData[payload.url].checksum = payload.checksum;
|
||||
|
||||
if (!this.config.updateOnFetch) {
|
||||
if (this.calendarDisplayer[payload.url] === undefined) {
|
||||
// calendar will never displayed, so display it
|
||||
this.updateDom(this.config.animationSpeed);
|
||||
// set this calendar as displayed
|
||||
this.calendarDisplayer[payload.url] = true;
|
||||
} else {
|
||||
Log.debug("[calendar] DOM not updated waiting self update()");
|
||||
if (this.hasCalendarURL(payload.url)) {
|
||||
// have we received events for this url
|
||||
if (!this.calendarData[payload.url]) {
|
||||
// no, setup the structure to hold the info
|
||||
this.calendarData[payload.url] = { events: null, checksum: null };
|
||||
}
|
||||
// save the event list
|
||||
this.calendarData[payload.url].events = payload.events;
|
||||
|
||||
this.error = null;
|
||||
this.loaded = true;
|
||||
|
||||
if (this.config.broadcastEvents) {
|
||||
this.broadcastEvents();
|
||||
}
|
||||
// if the checksum is the same
|
||||
if (this.calendarData[payload.url].checksum === payload.checksum) {
|
||||
// then don't update the UI
|
||||
return;
|
||||
}
|
||||
// haven't seen or the checksum is different
|
||||
this.calendarData[payload.url].checksum = payload.checksum;
|
||||
|
||||
if (!this.config.updateOnFetch) {
|
||||
if (this.calendarDisplayer[payload.url] === undefined) {
|
||||
// calendar will never displayed, so display it
|
||||
this.updateDom(this.config.animationSpeed);
|
||||
// set this calendar as displayed
|
||||
this.calendarDisplayer[payload.url] = true;
|
||||
} else {
|
||||
Log.debug("[calendar] DOM not updated waiting self update()");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (notification === "CALENDAR_ERROR") {
|
||||
let error_message = this.translate(payload.error_type);
|
||||
@@ -576,6 +580,21 @@ Module.register("calendar", {
|
||||
return wrapper;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if this config contains the calendar url.
|
||||
* @param {string} url The calendar url
|
||||
* @returns {boolean} True if the calendar config contains the url, False otherwise
|
||||
*/
|
||||
hasCalendarURL (url) {
|
||||
for (const calendar of this.config.calendars) {
|
||||
if (calendar.url === url) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* converts the given timestamp to a moment with a timezone
|
||||
* @param {number} timestamp timestamp from an event
|
||||
222
modules/default/calendar/calendarfetcher.js
Normal file
@@ -0,0 +1,222 @@
|
||||
const https = require("node:https");
|
||||
const ical = require("node-ical");
|
||||
const Log = require("logger");
|
||||
const CalendarFetcherUtils = require("./calendarfetcherutils");
|
||||
const { getUserAgent } = require("#server_functions");
|
||||
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||
const THIRTY_MINUTES = 30 * 60 * 1000;
|
||||
const MAX_SERVER_BACKOFF = 3;
|
||||
|
||||
/**
|
||||
* CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling
|
||||
* @class
|
||||
*/
|
||||
class CalendarFetcher {
|
||||
|
||||
/**
|
||||
* Creates a new CalendarFetcher instance
|
||||
* @param {string} url - The URL of the calendar to fetch
|
||||
* @param {number} reloadInterval - Time in ms between fetches
|
||||
* @param {string[]} excludedEvents - Event titles to exclude
|
||||
* @param {number} maximumEntries - Maximum number of events to return
|
||||
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
|
||||
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
|
||||
* @param {boolean} includePastEvents - Whether to include past events
|
||||
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
|
||||
*/
|
||||
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
|
||||
this.url = url;
|
||||
this.reloadInterval = reloadInterval;
|
||||
this.excludedEvents = excludedEvents;
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.maximumNumberOfDays = maximumNumberOfDays;
|
||||
this.auth = auth;
|
||||
this.includePastEvents = includePastEvents;
|
||||
this.selfSignedCert = selfSignedCert;
|
||||
|
||||
this.events = [];
|
||||
this.reloadTimer = null;
|
||||
this.serverErrorCount = 0;
|
||||
this.lastFetch = null;
|
||||
this.fetchFailedCallback = () => {};
|
||||
this.eventsReceivedCallback = () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending reload timer
|
||||
*/
|
||||
clearReloadTimer () {
|
||||
if (this.reloadTimer) {
|
||||
clearTimeout(this.reloadTimer);
|
||||
this.reloadTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next fetch respecting MagicMirror test mode
|
||||
* @param {number} delay - Delay in milliseconds
|
||||
*/
|
||||
scheduleNextFetch (delay) {
|
||||
const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval);
|
||||
if (process.env.mmTestMode === "true") {
|
||||
return;
|
||||
}
|
||||
this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the options object for fetch
|
||||
* @returns {object} Options object containing headers (and agent if needed)
|
||||
*/
|
||||
getRequestOptions () {
|
||||
const headers = { "User-Agent": getUserAgent() };
|
||||
const options = { headers };
|
||||
|
||||
if (this.selfSignedCert) {
|
||||
options.agent = new https.Agent({ rejectUnauthorized: false });
|
||||
}
|
||||
|
||||
if (this.auth) {
|
||||
if (this.auth.method === "bearer") {
|
||||
headers.Authorization = `Bearer ${this.auth.pass}`;
|
||||
} else {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Retry-After header value
|
||||
* @param {string} retryAfter - The Retry-After header value
|
||||
* @returns {number|null} Milliseconds to wait or null if parsing failed
|
||||
*/
|
||||
parseRetryAfter (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
const retryDate = Date.parse(retryAfter);
|
||||
if (!Number.isNaN(retryDate)) {
|
||||
return Math.max(0, retryDate - Date.now());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the retry delay for a non-ok response
|
||||
* @param {Response} response - The fetch Response object
|
||||
* @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay
|
||||
*/
|
||||
getDelayForResponse (response) {
|
||||
const { status, statusText = "" } = response;
|
||||
let delay = this.reloadInterval;
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
|
||||
Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`);
|
||||
} else if (status === 429) {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null;
|
||||
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else if (status >= 500) {
|
||||
this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF);
|
||||
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
|
||||
Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else if (status >= 400) {
|
||||
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else {
|
||||
Log.error(`${this.url} - Unexpected HTTP status ${status}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
delay,
|
||||
error: new Error(`HTTP ${status} ${statusText}`.trim())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and processes calendar data
|
||||
*/
|
||||
async fetchCalendar () {
|
||||
this.clearReloadTimer();
|
||||
|
||||
let nextDelay = this.reloadInterval;
|
||||
try {
|
||||
const response = await fetch(this.url, this.getRequestOptions());
|
||||
if (!response.ok) {
|
||||
const { delay, error } = this.getDelayForResponse(response);
|
||||
nextDelay = delay;
|
||||
this.fetchFailedCallback(this, error);
|
||||
} else {
|
||||
this.serverErrorCount = 0;
|
||||
const responseData = await response.text();
|
||||
try {
|
||||
const parsed = ical.parseICS(responseData);
|
||||
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
|
||||
this.events = CalendarFetcherUtils.filterEvents(parsed, {
|
||||
excludedEvents: this.excludedEvents,
|
||||
includePastEvents: this.includePastEvents,
|
||||
maximumEntries: this.maximumEntries,
|
||||
maximumNumberOfDays: this.maximumNumberOfDays
|
||||
});
|
||||
this.lastFetch = Date.now();
|
||||
this.broadcastEvents();
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - Fetch failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, error);
|
||||
}
|
||||
|
||||
this.scheduleNextFetch(nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enough time has passed since the last fetch to warrant a new one.
|
||||
* Uses reloadInterval as the threshold to respect user's configured fetchInterval.
|
||||
* @returns {boolean} True if a new fetch should be performed
|
||||
*/
|
||||
shouldRefetch () {
|
||||
if (!this.lastFetch) {
|
||||
return true;
|
||||
}
|
||||
const timeSinceLastFetch = Date.now() - this.lastFetch;
|
||||
return timeSinceLastFetch >= this.reloadInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts the current events to listeners
|
||||
*/
|
||||
broadcastEvents () {
|
||||
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
|
||||
this.eventsReceivedCallback(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback for successful event fetches
|
||||
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
|
||||
*/
|
||||
onReceive (callback) {
|
||||
this.eventsReceivedCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback for fetch failures
|
||||
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
|
||||
*/
|
||||
onError (callback) {
|
||||
this.fetchFailedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CalendarFetcher;
|
||||
408
modules/default/calendar/calendarfetcherutils.js
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @external Moment
|
||||
*/
|
||||
const moment = require("moment-timezone");
|
||||
|
||||
const Log = require("logger");
|
||||
|
||||
const CalendarFetcherUtils = {
|
||||
|
||||
/**
|
||||
* Determine based on the title of an event if it should be excluded from the list of events
|
||||
* @param {object} config the global config
|
||||
* @param {string} title the title of the event
|
||||
* @returns {object} excluded: true if the event should be excluded, false otherwise
|
||||
* until: the date until the event should be excluded.
|
||||
*/
|
||||
shouldEventBeExcluded (config, title) {
|
||||
for (const filterConfig of config.excludedEvents) {
|
||||
const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);
|
||||
if (match) {
|
||||
return {
|
||||
excluded: !match.until,
|
||||
until: match.until
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
excluded: false,
|
||||
until: null
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get local timezone.
|
||||
* This method makes it easier to test if different timezones cause problems by changing this implementation.
|
||||
* @returns {string} timezone
|
||||
*/
|
||||
getLocalTimezone () {
|
||||
return moment.tz.guess();
|
||||
},
|
||||
|
||||
/**
|
||||
* This function returns a list of moments for a recurring event.
|
||||
* @param {object} event the current event which is a recurring event
|
||||
* @param {moment.Moment} pastLocalMoment The past date to search for recurring events
|
||||
* @param {moment.Moment} futureLocalMoment The future date to search for recurring events
|
||||
* @param {number} durationInMs the duration of the event, this is used to take into account currently running events
|
||||
* @returns {moment.Moment[]} All moments for the recurring event
|
||||
*/
|
||||
getMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) {
|
||||
const rule = event.rrule;
|
||||
const isFullDayEvent = CalendarFetcherUtils.isFullDayEvent(event);
|
||||
const eventTimezone = event.start.tz || CalendarFetcherUtils.getLocalTimezone();
|
||||
|
||||
// rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars
|
||||
if (rule.origOptions?.dtstart?.getFullYear() < 1900) {
|
||||
rule.origOptions.dtstart.setFullYear(1900);
|
||||
}
|
||||
if (rule.options?.dtstart?.getFullYear() < 1900) {
|
||||
rule.options.dtstart.setFullYear(1900);
|
||||
}
|
||||
|
||||
// Expand search window to include ongoing events
|
||||
const oneDayInMs = 24 * 60 * 60 * 1000;
|
||||
const searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
|
||||
const searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
|
||||
|
||||
// For all-day events, extend "until" to end of day to include the final occurrence
|
||||
if (isFullDayEvent && rule.options?.until) {
|
||||
rule.options.until = moment(rule.options.until).endOf("day").toDate();
|
||||
}
|
||||
|
||||
// Clear tzid to prevent rrule.js from double-adjusting times
|
||||
if (rule.options) {
|
||||
rule.options.tzid = null;
|
||||
}
|
||||
|
||||
const dates = rule.between(searchFromDate, searchToDate, true) || [];
|
||||
|
||||
// Convert dates to moments in the appropriate timezone
|
||||
// rrule.js returns UTC dates with tzid cleared, so we interpret them in the event's original timezone
|
||||
return dates.map((date) => {
|
||||
if (isFullDayEvent) {
|
||||
// For all-day events, anchor to calendar day in event's timezone
|
||||
return moment.tz(date, eventTimezone).startOf("day");
|
||||
}
|
||||
// For timed events, preserve the time in the event's original timezone
|
||||
return moment.tz(date, "UTC").tz(eventTimezone, true);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter the events from ical according to the given config
|
||||
* @param {object} data the calendar data from ical
|
||||
* @param {object} config The configuration object
|
||||
* @returns {string[]} the filtered events
|
||||
*/
|
||||
filterEvents (data, config) {
|
||||
const newEvents = [];
|
||||
|
||||
const eventDate = function (event, time) {
|
||||
const startMoment = event[time].tz ? moment.tz(event[time], event[time].tz) : moment.tz(event[time], CalendarFetcherUtils.getLocalTimezone());
|
||||
return CalendarFetcherUtils.isFullDayEvent(event) ? startMoment.startOf("day") : startMoment;
|
||||
};
|
||||
|
||||
Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
|
||||
|
||||
const now = moment();
|
||||
const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now;
|
||||
const futureLocalMoment
|
||||
= now
|
||||
.clone()
|
||||
.startOf("day")
|
||||
.add(config.maximumNumberOfDays, "days")
|
||||
// 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]) => {
|
||||
Log.debug("Processing entry...");
|
||||
|
||||
const title = CalendarFetcherUtils.getTitleFromEvent(event);
|
||||
Log.debug(`title: ${title}`);
|
||||
|
||||
// Return quickly if event should be excluded.
|
||||
let { excluded, eventFilterUntil } = this.shouldEventBeExcluded(config, title);
|
||||
if (excluded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Ugly fix to solve the facebook birthday issue.
|
||||
// Otherwise, the recurring events only show the birthday for next year.
|
||||
let isFacebookBirthday = false;
|
||||
if (typeof event.uid !== "undefined") {
|
||||
if (event.uid.indexOf("@facebook.com") !== -1) {
|
||||
isFacebookBirthday = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "VEVENT") {
|
||||
Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`);
|
||||
let eventStartMoment = eventDate(event, "start");
|
||||
let eventEndMoment;
|
||||
|
||||
if (typeof event.end !== "undefined") {
|
||||
eventEndMoment = eventDate(event, "end");
|
||||
} else if (typeof event.duration !== "undefined") {
|
||||
eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration));
|
||||
} else {
|
||||
if (!isFacebookBirthday) {
|
||||
// make copy of start date, separate storage area
|
||||
eventEndMoment = eventStartMoment.clone();
|
||||
} else {
|
||||
eventEndMoment = eventStartMoment.clone().add(1, "days");
|
||||
}
|
||||
}
|
||||
|
||||
Log.debug(`start: ${eventStartMoment.toDate()}`);
|
||||
Log.debug(`end: ${eventEndMoment.toDate()}`);
|
||||
|
||||
// Calculate the duration of the event for use with recurring events.
|
||||
const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf();
|
||||
Log.debug(`duration: ${durationMs}`);
|
||||
|
||||
const location = event.location || false;
|
||||
const geo = event.geo || false;
|
||||
const description = event.description || false;
|
||||
|
||||
let instances = [];
|
||||
if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) {
|
||||
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
|
||||
} else {
|
||||
const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
|
||||
let end = eventEndMoment;
|
||||
if (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) {
|
||||
end = end.endOf("day");
|
||||
}
|
||||
|
||||
instances.push({
|
||||
event: event,
|
||||
startMoment: eventStartMoment,
|
||||
endMoment: end,
|
||||
isRecurring: false
|
||||
});
|
||||
}
|
||||
|
||||
for (const instance of instances) {
|
||||
const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance;
|
||||
|
||||
// Filter logic
|
||||
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
|
||||
const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
|
||||
|
||||
Log.debug(`saving event: ${title}`);
|
||||
newEvents.push({
|
||||
title: title,
|
||||
startDate: startMoment.format("x"),
|
||||
endDate: endMoment.format("x"),
|
||||
fullDayEvent: fullDay,
|
||||
recurringEvent: isRecurring,
|
||||
class: event.class,
|
||||
firstYear: event.start.getFullYear(),
|
||||
location: instanceEvent.location || location,
|
||||
geo: instanceEvent.geo || geo,
|
||||
description: instanceEvent.description || description
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
newEvents.sort(function (a, b) {
|
||||
return a.startDate - b.startDate;
|
||||
});
|
||||
|
||||
return newEvents;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the title from the event.
|
||||
* @param {object} event The event object to check.
|
||||
* @returns {string} The title of the event, or "Event" if no title is found.
|
||||
*/
|
||||
getTitleFromEvent (event) {
|
||||
let title = "Event";
|
||||
if (event.summary) {
|
||||
title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary;
|
||||
} else if (event.description) {
|
||||
title = event.description;
|
||||
}
|
||||
|
||||
return title;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if an event is a fullday event.
|
||||
* @param {object} event The event object to check.
|
||||
* @returns {boolean} True if the event is a fullday event, false otherwise
|
||||
*/
|
||||
isFullDayEvent (event) {
|
||||
if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const start = event.start || 0;
|
||||
const startDate = new Date(start);
|
||||
const end = event.end || 0;
|
||||
if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) {
|
||||
// Is 24 hours, and starts on the middle of the night.
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if the user defined time filter should apply
|
||||
* @param {moment.Moment} now Date object using previously created object for consistency
|
||||
* @param {moment.Moment} endDate Moment object representing the event end date
|
||||
* @param {string} filter The time to subtract from the end date to determine if an event should be shown
|
||||
* @returns {boolean} True if the event should be filtered out, false otherwise
|
||||
*/
|
||||
timeFilterApplies (now, endDate, filter) {
|
||||
if (filter) {
|
||||
const until = filter.split(" "),
|
||||
value = parseInt(until[0]),
|
||||
increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
|
||||
filterUntil = moment(endDate.format()).subtract(value, increment);
|
||||
|
||||
return now < filterUntil;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if the user defined title filter should apply
|
||||
* @param {string} title the title of the event
|
||||
* @param {string} filter the string to look for, can be a regex also
|
||||
* @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string
|
||||
* @param {string} regexFlags flags that should be applied to the regex
|
||||
* @returns {boolean} True if the title should be filtered out, false otherwise
|
||||
*/
|
||||
titleFilterApplies (title, filter, useRegex, regexFlags) {
|
||||
if (useRegex) {
|
||||
let regexFilter = filter;
|
||||
// Assume if leading slash, there is also trailing slash
|
||||
if (filter[0] === "/") {
|
||||
// Strip leading and trailing slashes
|
||||
regexFilter = filter.substr(1).slice(0, -1);
|
||||
}
|
||||
return new RegExp(regexFilter, regexFlags).test(title);
|
||||
} else {
|
||||
return title.includes(filter);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Expands a recurring event into individual event instances.
|
||||
* @param {object} event The recurring event object
|
||||
* @param {moment.Moment} pastLocalMoment The past date limit
|
||||
* @param {moment.Moment} futureLocalMoment The future date limit
|
||||
* @param {number} durationMs The duration of the event in milliseconds
|
||||
* @returns {object[]} Array of event instances
|
||||
*/
|
||||
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationMs) {
|
||||
const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
|
||||
const instances = [];
|
||||
|
||||
for (const startMoment of moments) {
|
||||
let curEvent = event;
|
||||
let showRecurrence = true;
|
||||
let recurringEventStartMoment = startMoment.clone().tz(CalendarFetcherUtils.getLocalTimezone());
|
||||
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
|
||||
|
||||
const dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
|
||||
|
||||
// Check for overrides
|
||||
if (curEvent.recurrences !== undefined) {
|
||||
if (curEvent.recurrences[dateKey] !== undefined) {
|
||||
curEvent = curEvent.recurrences[dateKey];
|
||||
// Re-calculate start/end based on override
|
||||
const start = curEvent.start;
|
||||
const end = curEvent.end;
|
||||
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
|
||||
|
||||
recurringEventStartMoment = (start.tz ? moment(start).tz(start.tz) : moment(start)).tz(localTimezone);
|
||||
recurringEventEndMoment = (end.tz ? moment(end).tz(end.tz) : moment(end)).tz(localTimezone);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exceptions
|
||||
if (curEvent.exdate !== undefined) {
|
||||
if (curEvent.exdate[dateKey] !== undefined) {
|
||||
showRecurrence = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
|
||||
recurringEventEndMoment = recurringEventEndMoment.endOf("day");
|
||||
}
|
||||
|
||||
if (showRecurrence) {
|
||||
instances.push({
|
||||
event: curEvent,
|
||||
startMoment: recurringEventStartMoment,
|
||||
endMoment: recurringEventEndMoment,
|
||||
isRecurring: true
|
||||
});
|
||||
}
|
||||
}
|
||||
return instances;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if an event title matches a specific filter configuration.
|
||||
* @param {string} title The event title to check
|
||||
* @param {string|object} filterConfig The filter configuration (string or object)
|
||||
* @returns {object|null} Object with {until: string|null} if matched, null otherwise
|
||||
*/
|
||||
checkEventAgainstFilter (title, filterConfig) {
|
||||
let filter = filterConfig;
|
||||
let testTitle = title.toLowerCase();
|
||||
let until = null;
|
||||
let useRegex = false;
|
||||
let regexFlags = "g";
|
||||
|
||||
if (filter instanceof Object) {
|
||||
if (typeof filter.until !== "undefined") {
|
||||
until = filter.until;
|
||||
}
|
||||
|
||||
if (typeof filter.regex !== "undefined") {
|
||||
useRegex = filter.regex;
|
||||
}
|
||||
|
||||
if (filter.caseSensitive) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
} else if (useRegex) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
regexFlags += "i";
|
||||
} else {
|
||||
filter = filter.filterBy.toLowerCase();
|
||||
}
|
||||
} else {
|
||||
filter = filter.toLowerCase();
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
|
||||
return { until };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = CalendarFetcherUtils;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* of starting the MagicMirror² core. Adjust the values below to your desire.
|
||||
*/
|
||||
// Load internal alias resolver
|
||||
require("../../js/alias-resolver");
|
||||
require("../../../js/alias-resolver");
|
||||
const Log = require("logger");
|
||||
|
||||
const CalendarFetcher = require("./calendarfetcher");
|
||||
@@ -61,11 +61,12 @@ module.exports = NodeHelper.create({
|
||||
this.broadcastEvents(fetcher, identifier);
|
||||
});
|
||||
|
||||
fetcher.onError((fetcher, errorInfo) => {
|
||||
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, errorInfo.message || errorInfo);
|
||||
fetcher.onError((fetcher, error) => {
|
||||
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error);
|
||||
let error_type = NodeHelper.checkFetchError(error);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", {
|
||||
id: identifier,
|
||||
error_type: errorInfo.translationKey
|
||||
error_type
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |