diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000000..f58390678b --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Scope and Deployment + +MagicMirror is primarily intended for trusted local/private network environments. +Direct public exposure to the internet or other untrusted networks is not recommended. + +We take security seriously and encourage responsible disclosure of vulnerabilities to help us improve the software. + +## Reporting a Vulnerability + +**Please keep vulnerability details private** — do not post them in public GitHub issues. + +Instead, reach out privately via the MagicMirror forum to one of the core developers: + +- [rejas](https://forum.magicmirror.builders/user/rejas) +- [karsten13](https://forum.magicmirror.builders/user/karsten13) +- [sdetweil](https://forum.magicmirror.builders/user/sdetweil) +- [Kristjan](https://forum.magicmirror.builders/user/kristjanesperanto) + +Please include, if possible: + +- Affected version(s) +- Reproduction steps or proof-of-concept +- What could an attacker do with this? +- Any ideas how to fix it? + +## Coordinated Disclosure + +We will keep reported vulnerabilities private until a fix is available and coordinate the disclosure timeline with you. +We aim to respond as quickly as possible. diff --git a/.github/workflows/automated-tests.yaml b/.github/workflows/automated-tests.yaml index 0c44132be5..086c8bf4f8 100644 --- a/.github/workflows/automated-tests.yaml +++ b/.github/workflows/automated-tests.yaml @@ -18,7 +18,7 @@ concurrency: jobs: code-style-check: - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 15 steps: - name: "Checkout code" @@ -42,7 +42,7 @@ jobs: timeout-minutes: 30 strategy: matrix: - node-version: [22.21.1, 22.x, 24.x] + node-version: [22.x, 24.x, 25.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 css/custom.css + touch config/custom.css - name: "Run tests" run: | export WAYLAND_DISPLAY=wayland-0 diff --git a/.github/workflows/dep-review.yaml b/.github/workflows/dep-review.yaml index 9bad70f358..e78fd30098 100644 --- a/.github/workflows/dep-review.yaml +++ b/.github/workflows/dep-review.yaml @@ -10,7 +10,7 @@ permissions: jobs: dependency-review: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: "Checkout code" uses: actions/checkout@v6 diff --git a/.github/workflows/electron-rebuild.yaml b/.github/workflows/electron-rebuild.yaml index de6462fed7..e9cad3346c 100644 --- a/.github/workflows/electron-rebuild.yaml +++ b/.github/workflows/electron-rebuild.yaml @@ -5,10 +5,10 @@ on: [pull_request] jobs: rebuild: name: Run electron-rebuild - runs-on: ubuntu-latest + runs-on: ubuntu-slim strategy: matrix: - node-version: [22.21.1, 22.x, 24.x] + node-version: [22.x, 24.x, 25.x] steps: - name: Checkout code uses: actions/checkout@v6 diff --git a/.github/workflows/enforce-pullrequest-rules.yaml b/.github/workflows/enforce-pullrequest-rules.yaml index 85ae4496a2..aa4d51beb9 100644 --- a/.github/workflows/enforce-pullrequest-rules.yaml +++ b/.github/workflows/enforce-pullrequest-rules.yaml @@ -12,7 +12,7 @@ on: jobs: check: - runs-on: ubuntu-latest + runs-on: ubuntu-slim if: github.event_name == 'pull_request' timeout-minutes: 10 steps: diff --git a/.github/workflows/release-notes.yaml b/.github/workflows/release-notes.yaml index d992fdfe8d..12303e5d01 100644 --- a/.github/workflows/release-notes.yaml +++ b/.github/workflows/release-notes.yaml @@ -15,7 +15,7 @@ concurrency: jobs: release-notes: - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 15 steps: - name: "Checkout code" diff --git a/.github/workflows/spellcheck.yaml b/.github/workflows/spellcheck.yaml index 7c9514538c..600386e3b8 100644 --- a/.github/workflows/spellcheck.yaml +++ b/.github/workflows/spellcheck.yaml @@ -12,7 +12,7 @@ permissions: jobs: spellcheck: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: Checkout code uses: actions/checkout@v6 diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 963fd0db0a..5660e986b6 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -10,7 +10,7 @@ permissions: jobs: stale: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: actions/stale@v10 with: diff --git a/.gitignore b/.gitignore index ea15d0ba2d..e1365c2fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,20 +54,13 @@ Temporary Items .directory .Trash-* -# Ignore all modules except the default modules. +# Ignore all modules /modules/* -!/modules/default -# 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. +# Ignore users config file but keep the samples. config !config/config.js.sample +!config/custom.css.sample # Vim ## swap diff --git a/Collaboration.md b/Collaboration.md index e06904c1a5..39520a9059 100644 --- a/Collaboration.md +++ b/Collaboration.md @@ -46,6 +46,7 @@ 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`) @@ -61,11 +62,24 @@ Are done by ### After release -- [ ] publish release notes with link to github release on forum in new locked topic +- [ ] 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) - [ ] 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 - - [ ] 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` + - [ ] **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//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 + ``` diff --git a/README.md b/README.md index 38d708d372..873bf35ea0 100644 --- a/README.md +++ b/README.md @@ -51,5 +51,10 @@ 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.

- MagPi Top 50 + + + + MagPi Top 50 + +

diff --git a/clientonly/index.js b/clientonly/index.js index e1ad59c5de..bf6d6cdf87 100644 --- a/clientonly/index.js +++ b/clientonly/index.js @@ -1,136 +1,167 @@ "use strict"; -// Use separate scope to prevent global scope pollution -(function () { +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 () { const config = {}; - /** - * Helper function to get server address/hostname from either the commandline or env - */ - function getServerAddress () { - - /** - * 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; - } - - // Prefer command line arguments over environment variables - ["address", "port"].forEach((key) => { - config[key] = getCommandLineParameter(key, process.env[key.toUpperCase()]); - }); - - // determine if "--use-tls"-flag was provided - config.tls = process.argv.indexOf("--use-tls") > 0; - } - - /** - * 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 = ""; - - // Gather incoming data - response.on("data", function (chunk) { - configData += chunk; - }); - // Resolve promise at the end of the HTTP/HTTPS stream - response.on("end", function () { + // 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; + + // 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}`)); - }); + 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]'"); } - - /** - * 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); + 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 { - console.log("Usage: 'node clientonly --address 192.168.1.10 --port 8080 [--use-tls]'"); + fail("Error: Requires environment variable WAYLAND_DISPLAY or DISPLAY, none is provided."); } - 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(); + // 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}`); + }); + + // 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})`); } -}()); +} + +// Main execution +const config = getServerParameters(); +const prefix = config.tls ? "https://" : "http://"; + +// 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.`); +} + +// 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(); +} diff --git a/css/custom.css.sample b/config/custom.css.sample similarity index 100% rename from css/custom.css.sample rename to config/custom.css.sample diff --git a/cspell.config.json b/cspell.config.json index ec3afdc3f6..1fa1ba6873 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -179,6 +179,7 @@ "Lightspeed", "loadingcircle", "locationforecast", + "logg", "lockstring", "lstrip", "Luciella", @@ -247,6 +248,7 @@ "Reis", "rejas", "relativehumidity", + "resultstring", "Resig", "roboto", "rohitdharavath", @@ -285,6 +287,9 @@ "Teeuw", "Teil", "TESTMODE", + "testpass", + "testuser", + "teststring", "thomasrockhu", "thumbslider", "timeformat", @@ -307,6 +312,7 @@ "VEVENT", "vgtu", "Vitest", + "VCALENDAR", "Voelt", "Vorberechnung", "vppencilsharpener", @@ -326,6 +332,7 @@ "winddirection", "windgusts", "windspeed", + "WKST", "Woolridge", "worktree", "Wsymb", @@ -343,11 +350,11 @@ "ignorePaths": [ "css/roboto.css", "node_modules/**", - "modules/!(default)/**", - "modules/default/**/translations/!(en).json", - "modules/default/calendar/windowsZones.json", - "modules/default/clock/faces/*.svg", - "modules/default/weather/providers/yr.js", + "modules/**", + "defaultmodules/**/translations/!(en).json", + "defaultmodules/calendar/windowsZones.json", + "defaultmodules/clock/faces/*.svg", + "defaultmodules/weather/providers/yr.js", "tests/mocks/**", "tests/e2e/modules/clock_es_spec.js", "translations/**" diff --git a/modules/default/alert/README.md b/defaultmodules/alert/README.md similarity index 100% rename from modules/default/alert/README.md rename to defaultmodules/alert/README.md diff --git a/modules/default/alert/alert.js b/defaultmodules/alert/alert.js similarity index 100% rename from modules/default/alert/alert.js rename to defaultmodules/alert/alert.js diff --git a/modules/default/alert/notificationFx.js b/defaultmodules/alert/notificationFx.js similarity index 100% rename from modules/default/alert/notificationFx.js rename to defaultmodules/alert/notificationFx.js diff --git a/modules/default/alert/styles/center.css b/defaultmodules/alert/styles/center.css similarity index 100% rename from modules/default/alert/styles/center.css rename to defaultmodules/alert/styles/center.css diff --git a/modules/default/alert/styles/left.css b/defaultmodules/alert/styles/left.css similarity index 100% rename from modules/default/alert/styles/left.css rename to defaultmodules/alert/styles/left.css diff --git a/modules/default/alert/styles/notificationFx.css b/defaultmodules/alert/styles/notificationFx.css similarity index 100% rename from modules/default/alert/styles/notificationFx.css rename to defaultmodules/alert/styles/notificationFx.css diff --git a/modules/default/alert/styles/right.css b/defaultmodules/alert/styles/right.css similarity index 100% rename from modules/default/alert/styles/right.css rename to defaultmodules/alert/styles/right.css diff --git a/modules/default/alert/templates/alert.njk b/defaultmodules/alert/templates/alert.njk similarity index 100% rename from modules/default/alert/templates/alert.njk rename to defaultmodules/alert/templates/alert.njk diff --git a/modules/default/alert/templates/notification.njk b/defaultmodules/alert/templates/notification.njk similarity index 100% rename from modules/default/alert/templates/notification.njk rename to defaultmodules/alert/templates/notification.njk diff --git a/modules/default/alert/translations/bg.json b/defaultmodules/alert/translations/bg.json similarity index 100% rename from modules/default/alert/translations/bg.json rename to defaultmodules/alert/translations/bg.json diff --git a/modules/default/alert/translations/da.json b/defaultmodules/alert/translations/da.json similarity index 100% rename from modules/default/alert/translations/da.json rename to defaultmodules/alert/translations/da.json diff --git a/modules/default/alert/translations/de.json b/defaultmodules/alert/translations/de.json similarity index 100% rename from modules/default/alert/translations/de.json rename to defaultmodules/alert/translations/de.json diff --git a/modules/default/alert/translations/el.json b/defaultmodules/alert/translations/el.json similarity index 100% rename from modules/default/alert/translations/el.json rename to defaultmodules/alert/translations/el.json diff --git a/modules/default/alert/translations/en.json b/defaultmodules/alert/translations/en.json similarity index 100% rename from modules/default/alert/translations/en.json rename to defaultmodules/alert/translations/en.json diff --git a/modules/default/alert/translations/eo.json b/defaultmodules/alert/translations/eo.json similarity index 100% rename from modules/default/alert/translations/eo.json rename to defaultmodules/alert/translations/eo.json diff --git a/modules/default/alert/translations/es.json b/defaultmodules/alert/translations/es.json similarity index 100% rename from modules/default/alert/translations/es.json rename to defaultmodules/alert/translations/es.json diff --git a/modules/default/alert/translations/fr.json b/defaultmodules/alert/translations/fr.json similarity index 100% rename from modules/default/alert/translations/fr.json rename to defaultmodules/alert/translations/fr.json diff --git a/modules/default/alert/translations/hu.json b/defaultmodules/alert/translations/hu.json similarity index 100% rename from modules/default/alert/translations/hu.json rename to defaultmodules/alert/translations/hu.json diff --git a/modules/default/alert/translations/nl.json b/defaultmodules/alert/translations/nl.json similarity index 100% rename from modules/default/alert/translations/nl.json rename to defaultmodules/alert/translations/nl.json diff --git a/modules/default/alert/translations/pt-br.json b/defaultmodules/alert/translations/pt-br.json similarity index 100% rename from modules/default/alert/translations/pt-br.json rename to defaultmodules/alert/translations/pt-br.json diff --git a/modules/default/alert/translations/pt.json b/defaultmodules/alert/translations/pt.json similarity index 100% rename from modules/default/alert/translations/pt.json rename to defaultmodules/alert/translations/pt.json diff --git a/modules/default/alert/translations/ru.json b/defaultmodules/alert/translations/ru.json similarity index 100% rename from modules/default/alert/translations/ru.json rename to defaultmodules/alert/translations/ru.json diff --git a/modules/default/alert/translations/th.json b/defaultmodules/alert/translations/th.json similarity index 100% rename from modules/default/alert/translations/th.json rename to defaultmodules/alert/translations/th.json diff --git a/modules/default/calendar/README.md b/defaultmodules/calendar/README.md similarity index 100% rename from modules/default/calendar/README.md rename to defaultmodules/calendar/README.md diff --git a/modules/default/calendar/calendar.css b/defaultmodules/calendar/calendar.css similarity index 100% rename from modules/default/calendar/calendar.css rename to defaultmodules/calendar/calendar.css diff --git a/modules/default/calendar/calendar.js b/defaultmodules/calendar/calendar.js similarity index 72% rename from modules/default/calendar/calendar.js rename to defaultmodules/calendar/calendar.js index 51270497df..cfc625431a 100644 --- a/modules/default/calendar/calendar.js +++ b/defaultmodules/calendar/calendar.js @@ -169,9 +169,7 @@ Module.register("calendar", { notificationReceived (notification, payload, sender) { if (notification === "FETCH_CALENDAR") { - if (this.hasCalendarURL(payload.url)) { - this.sendSocketNotification(notification, { url: payload.url, id: this.identifier }); - } + this.sendSocketNotification(notification, { url: payload.url, id: this.identifier }); } }, @@ -183,40 +181,38 @@ Module.register("calendar", { } if (notification === "CALENDAR_EVENTS") { - 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; + // 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; - 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; + this.error = null; + this.loaded = true; - 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; + 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; } } else if (notification === "CALENDAR_ERROR") { let error_message = this.translate(payload.error_type); @@ -388,32 +384,7 @@ Module.register("calendar", { } if (this.config.timeFormat === "dateheaders") { - if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); - - if (event.fullDayEvent) { - titleWrapper.colSpan = "2"; - titleWrapper.classList.add("align-left"); - } else { - const timeWrapper = document.createElement("td"); - timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`; - timeWrapper.style.paddingLeft = "2px"; - timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; - timeWrapper.innerHTML = eventStartDateMoment.format("LT"); - - // Add endDate to dataheaders if showEnd is enabled - if (this.config.showEnd) { - if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) { - // no duration here, don't display end - } else { - timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`; - } - } - - eventWrapper.appendChild(timeWrapper); - - if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); - } - if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); + this.renderDateHeadersEventTime(eventWrapper, titleWrapper, event, eventStartDateMoment, eventEndDateMoment); } else { const timeWrapper = document.createElement("td"); @@ -421,106 +392,11 @@ Module.register("calendar", { const now = moment(); if (this.config.timeFormat === "absolute") { - // Use dateFormat - timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat)); - // Add end time if showEnd - if (this.config.showEnd) { - // and has a duration - if (event.startDate !== event.endDate) { - timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat)); - } - } - - // For full day events we use the fullDayEventDateFormat - if (event.fullDayEvent) { - //subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day - eventEndDateMoment.subtract(1, "second"); - timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat)); - // only show end if requested and allowed and the dates are different - if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) { - timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat)); - } else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) { - timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat)); - } - } else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) { - // Ongoing and getRelative is set - timeWrapper.innerHTML = CalendarUtils.capFirst( - this.translate("RUNNING", { - fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, - timeUntilEnd: eventEndDateMoment.fromNow(true) - }) - ); - } else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) { - // Within urgency days - timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow()); - } - if (event.fullDayEvent && this.config.nextDaysRelative) { - // Full days events within the next two days - if (event.today) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); - } else if (event.yesterday) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); - } else if (event.tomorrow) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); - } else if (event.dayAfterTomorrow) { - if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); - } - } - } + timeWrapper.innerHTML = this.buildAbsoluteTimeText(event, eventStartDateMoment, eventEndDateMoment, now); } else { - // Show relative times - if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { - // Use relative time - if (!this.config.hideTime && !event.fullDayEvent) { - Log.debug("[calendar] event not hidden and not fullday"); - timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`; - } else { - Log.debug("[calendar] event full day or hidden"); - timeWrapper.innerHTML = `${CalendarUtils.capFirst( - eventStartDateMoment.calendar(null, { - sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`, - nextDay: `[${this.translate("TOMORROW")}]`, - nextWeek: "dddd", - sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat - }) - )}`; - } - if (event.fullDayEvent) { - // Full days events within the next two days - if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); - } else if (event.dayBeforeYesterday) { - if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY")); - } - } else if (event.yesterday) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); - } else if (event.tomorrow) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); - } else if (event.dayAfterTomorrow) { - if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); - } - } - Log.info("[calendar] event fullday"); - } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) { - Log.info("[calendar] not full day but within getRelative size"); - // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() - timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; - } - } else { - // Ongoing event - timeWrapper.innerHTML = CalendarUtils.capFirst( - this.translate("RUNNING", { - fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, - timeUntilEnd: eventEndDateMoment.fromNow(true) - }) - ); - } + timeWrapper.innerHTML = this.buildRelativeTimeText(event, eventStartDateMoment, eventEndDateMoment, now); } + timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`; eventWrapper.appendChild(timeWrapper); } @@ -580,21 +456,6 @@ 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 @@ -812,6 +673,226 @@ Module.register("calendar", { ); }, + createDateHeadersTimeWrapper (url) { + const timeWrapper = document.createElement("td"); + timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(url)}`; + timeWrapper.style.paddingLeft = "2px"; + timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; + return timeWrapper; + }, + + hasEventDuration (event) { + return event.startDate !== event.endDate; + }, + + shouldShowDateHeadersTimedEnd (event) { + return this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event)); + }, + + shouldShowRelativeTimedEnd (event) { + return !this.config.hideTime && this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event)); + }, + + getAdjustedFullDayEndMoment (endMoment) { + return endMoment.clone().subtract(1, "second"); + }, + + renderDateHeadersEventTime (eventWrapper, titleWrapper, event, eventStartDateMoment, eventEndDateMoment) { + if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); + + if (event.fullDayEvent) { + const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment); + if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) { + const timeWrapper = this.createDateHeadersTimeWrapper(event.url); + timeWrapper.innerHTML = `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`; + eventWrapper.appendChild(timeWrapper); + if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); + } else { + titleWrapper.colSpan = "2"; + titleWrapper.classList.add("align-left"); + } + } else { + const timeWrapper = this.createDateHeadersTimeWrapper(event.url); + timeWrapper.innerHTML = eventStartDateMoment.format("LT"); + + // In dateheaders mode, keep the end as time-only to avoid redundant date info under a date header. + if (this.shouldShowDateHeadersTimedEnd(event)) { + timeWrapper.innerHTML += `-${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`; + } + + eventWrapper.appendChild(timeWrapper); + if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); + } + + if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); + }, + + buildAbsoluteTimeText (event, eventStartDateMoment, eventEndDateMoment, now) { + let timeText = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat)); + + if (this.config.showEnd && (!this.config.showEndsOnlyWithDuration || this.hasEventDuration(event))) { + const sameDay = this.isSameDay(eventStartDateMoment, eventEndDateMoment); + if (sameDay && !this.dateFormatIncludesTime()) { + timeText += `, ${eventStartDateMoment.format("LT")}`; + } + timeText += `-${this.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment)}`; + } + + if (event.fullDayEvent) { + const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment); + timeText = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat)); + + if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) { + timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`; + } else if (!eventStartDateMoment.isSame(adjustedEndMoment, "d") && eventStartDateMoment.isBefore(now)) { + timeText = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat)); + } + + if (this.config.nextDaysRelative) { + let relativeLabel = false; + if (event.today) { + timeText = CalendarUtils.capFirst(this.translate("TODAY")); + relativeLabel = true; + } else if (event.yesterday) { + timeText = CalendarUtils.capFirst(this.translate("YESTERDAY")); + relativeLabel = true; + } else if (event.tomorrow) { + timeText = CalendarUtils.capFirst(this.translate("TOMORROW")); + relativeLabel = true; + } else if (event.dayAfterTomorrow && this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeText = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); + relativeLabel = true; + } + + if (relativeLabel && this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(adjustedEndMoment, "d")) { + timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`; + } + } + + return timeText; + } + + if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) { + return CalendarUtils.capFirst( + this.translate("RUNNING", { + fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: eventEndDateMoment.fromNow(true) + }) + ); + } + + if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) { + return CalendarUtils.capFirst(eventStartDateMoment.fromNow()); + } + + return timeText; + }, + + buildRelativeTimeText (event, eventStartDateMoment, eventEndDateMoment, now) { + if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { + let timeText; + + if (!this.config.hideTime && !event.fullDayEvent) { + Log.debug("[calendar] event not hidden and not fullday"); + timeText = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`; + } else { + Log.debug("[calendar] event full day or hidden"); + timeText = `${CalendarUtils.capFirst( + eventStartDateMoment.calendar(null, { + sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`, + nextDay: `[${this.translate("TOMORROW")}]`, + nextWeek: "dddd", + sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat + }) + )}`; + } + + if (event.fullDayEvent) { + if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { + timeText = CalendarUtils.capFirst(this.translate("TODAY")); + } else if (event.dayBeforeYesterday) { + if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { + timeText = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY")); + } + } else if (event.yesterday) { + timeText = CalendarUtils.capFirst(this.translate("YESTERDAY")); + } else if (event.tomorrow) { + timeText = CalendarUtils.capFirst(this.translate("TOMORROW")); + } else if (event.dayAfterTomorrow) { + if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeText = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); + } + } + + if (this.config.showEnd && !this.config.showEndsOnlyWithDuration) { + const adjustedEndMoment = this.getAdjustedFullDayEndMoment(eventEndDateMoment); + if (!eventStartDateMoment.isSame(adjustedEndMoment, "d")) { + timeText += `-${CalendarUtils.capFirst(adjustedEndMoment.format(this.config.fullDayEventDateFormat))}`; + } + } + + Log.info("[calendar] event fullday"); + } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) { + Log.info("[calendar] not full day but within getRelative size"); + timeText = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; + } else if (this.shouldShowRelativeTimedEnd(event)) { + if (this.isSameDay(eventStartDateMoment, eventEndDateMoment)) { + const sameElseFormat = this.dateFormatIncludesTime() ? this.config.dateFormat : `${this.config.dateFormat}, LT`; + timeText = CalendarUtils.capFirst( + eventStartDateMoment.calendar(null, { sameElse: sameElseFormat }) + ); + } + timeText += `-${this.formatTimedEventEnd(eventStartDateMoment, eventEndDateMoment)}`; + } + + return timeText; + } + + return CalendarUtils.capFirst( + this.translate("RUNNING", { + fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: eventEndDateMoment.fromNow(true) + }) + ); + }, + + /** + * Determines whether two moments are on the same day. + * @param {moment.Moment} startMoment The start moment. + * @param {moment.Moment} endMoment The end moment. + * @returns {boolean} True when both moments share the same calendar day. + */ + isSameDay (startMoment, endMoment) { + return startMoment.isSame(endMoment, "d"); + }, + + /** + * Checks whether the configured dateFormat already contains time components. + * @returns {boolean} True when dateFormat includes time tokens. + */ + dateFormatIncludesTime () { + const dateFormatWithoutLiterals = this.config.dateFormat.replace(/\[[^\]]*\]/g, ""); + const localeDateFormat = moment.localeData(); + const expandedDateFormat = dateFormatWithoutLiterals.replace( + /LTS|LT|LLLL|LLL|LL|L|llll|lll|ll|l/g, + (token) => localeDateFormat.longDateFormat(token) || token + ); + const expandedDateFormatWithoutLiterals = expandedDateFormat.replace(/\[[^\]]*\]/g, ""); + return (/(H{1,2}|h{1,2}|k{1,2}|m{1,2}|s{1,2}|a|A)/).test(expandedDateFormatWithoutLiterals); + }, + + /** + * Formats a timed event end value. + * Uses time-only for same-day events and dateEndFormat for multi-day events. + * @param {moment.Moment} startMoment The event start moment. + * @param {moment.Moment} endMoment The event end moment. + * @returns {string} The formatted end value. + */ + formatTimedEventEnd (startMoment, endMoment) { + const endFormat = this.isSameDay(startMoment, endMoment) ? "LT" : this.config.dateEndFormat; + return CalendarUtils.capFirst(endMoment.format(endFormat)); + }, + /** * Retrieves the symbolClass for a specific calendar url. * @param {string} url The calendar url diff --git a/defaultmodules/calendar/calendarfetcher.js b/defaultmodules/calendar/calendarfetcher.js new file mode 100644 index 0000000000..557f99946c --- /dev/null +++ b/defaultmodules/calendar/calendarfetcher.js @@ -0,0 +1,129 @@ +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; diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js new file mode 100644 index 0000000000..7234514d72 --- /dev/null +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -0,0 +1,276 @@ +/** + * @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; +} diff --git a/modules/default/calendar/calendarutils.js b/defaultmodules/calendar/calendarutils.js similarity index 100% rename from modules/default/calendar/calendarutils.js rename to defaultmodules/calendar/calendarutils.js diff --git a/modules/default/calendar/debug.js b/defaultmodules/calendar/debug.js similarity index 96% rename from modules/default/calendar/debug.js rename to defaultmodules/calendar/debug.js index 53a0d78046..87f2b788f2 100644 --- a/modules/default/calendar/debug.js +++ b/defaultmodules/calendar/debug.js @@ -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"); diff --git a/modules/default/calendar/node_helper.js b/defaultmodules/calendar/node_helper.js similarity index 96% rename from modules/default/calendar/node_helper.js rename to defaultmodules/calendar/node_helper.js index 5b717b5b1b..de69e4e075 100644 --- a/modules/default/calendar/node_helper.js +++ b/defaultmodules/calendar/node_helper.js @@ -61,12 +61,11 @@ module.exports = NodeHelper.create({ this.broadcastEvents(fetcher, identifier); }); - fetcher.onError((fetcher, error) => { - Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error); - let error_type = NodeHelper.checkFetchError(error); + fetcher.onError((fetcher, errorInfo) => { + Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, errorInfo.message || errorInfo); this.sendSocketNotification("CALENDAR_ERROR", { id: identifier, - error_type + error_type: errorInfo.translationKey }); }); diff --git a/modules/default/calendar/windowsZones.json b/defaultmodules/calendar/windowsZones.json similarity index 100% rename from modules/default/calendar/windowsZones.json rename to defaultmodules/calendar/windowsZones.json diff --git a/modules/default/clock/README.md b/defaultmodules/clock/README.md similarity index 100% rename from modules/default/clock/README.md rename to defaultmodules/clock/README.md diff --git a/modules/default/clock/clock.js b/defaultmodules/clock/clock.js similarity index 100% rename from modules/default/clock/clock.js rename to defaultmodules/clock/clock.js diff --git a/modules/default/clock/clock_styles.css b/defaultmodules/clock/clock_styles.css similarity index 100% rename from modules/default/clock/clock_styles.css rename to defaultmodules/clock/clock_styles.css diff --git a/modules/default/clock/faces/face-001.svg b/defaultmodules/clock/faces/face-001.svg similarity index 100% rename from modules/default/clock/faces/face-001.svg rename to defaultmodules/clock/faces/face-001.svg diff --git a/modules/default/clock/faces/face-002.svg b/defaultmodules/clock/faces/face-002.svg similarity index 100% rename from modules/default/clock/faces/face-002.svg rename to defaultmodules/clock/faces/face-002.svg diff --git a/modules/default/clock/faces/face-003.svg b/defaultmodules/clock/faces/face-003.svg similarity index 100% rename from modules/default/clock/faces/face-003.svg rename to defaultmodules/clock/faces/face-003.svg diff --git a/modules/default/clock/faces/face-004.svg b/defaultmodules/clock/faces/face-004.svg similarity index 100% rename from modules/default/clock/faces/face-004.svg rename to defaultmodules/clock/faces/face-004.svg diff --git a/modules/default/clock/faces/face-005.svg b/defaultmodules/clock/faces/face-005.svg similarity index 100% rename from modules/default/clock/faces/face-005.svg rename to defaultmodules/clock/faces/face-005.svg diff --git a/modules/default/clock/faces/face-006.svg b/defaultmodules/clock/faces/face-006.svg similarity index 100% rename from modules/default/clock/faces/face-006.svg rename to defaultmodules/clock/faces/face-006.svg diff --git a/modules/default/clock/faces/face-007.svg b/defaultmodules/clock/faces/face-007.svg similarity index 100% rename from modules/default/clock/faces/face-007.svg rename to defaultmodules/clock/faces/face-007.svg diff --git a/modules/default/clock/faces/face-008.svg b/defaultmodules/clock/faces/face-008.svg similarity index 100% rename from modules/default/clock/faces/face-008.svg rename to defaultmodules/clock/faces/face-008.svg diff --git a/modules/default/clock/faces/face-009.svg b/defaultmodules/clock/faces/face-009.svg similarity index 100% rename from modules/default/clock/faces/face-009.svg rename to defaultmodules/clock/faces/face-009.svg diff --git a/modules/default/clock/faces/face-010.svg b/defaultmodules/clock/faces/face-010.svg similarity index 100% rename from modules/default/clock/faces/face-010.svg rename to defaultmodules/clock/faces/face-010.svg diff --git a/modules/default/clock/faces/face-011.svg b/defaultmodules/clock/faces/face-011.svg similarity index 100% rename from modules/default/clock/faces/face-011.svg rename to defaultmodules/clock/faces/face-011.svg diff --git a/modules/default/clock/faces/face-012.svg b/defaultmodules/clock/faces/face-012.svg similarity index 100% rename from modules/default/clock/faces/face-012.svg rename to defaultmodules/clock/faces/face-012.svg diff --git a/modules/default/compliments/README.md b/defaultmodules/compliments/README.md similarity index 100% rename from modules/default/compliments/README.md rename to defaultmodules/compliments/README.md diff --git a/modules/default/compliments/compliments.js b/defaultmodules/compliments/compliments.js similarity index 100% rename from modules/default/compliments/compliments.js rename to defaultmodules/compliments/compliments.js diff --git a/modules/default/defaultmodules.js b/defaultmodules/defaultmodules.js similarity index 100% rename from modules/default/defaultmodules.js rename to defaultmodules/defaultmodules.js diff --git a/modules/default/helloworld/README.md b/defaultmodules/helloworld/README.md similarity index 100% rename from modules/default/helloworld/README.md rename to defaultmodules/helloworld/README.md diff --git a/modules/default/helloworld/helloworld.js b/defaultmodules/helloworld/helloworld.js similarity index 100% rename from modules/default/helloworld/helloworld.js rename to defaultmodules/helloworld/helloworld.js diff --git a/modules/default/helloworld/helloworld.njk b/defaultmodules/helloworld/helloworld.njk similarity index 100% rename from modules/default/helloworld/helloworld.njk rename to defaultmodules/helloworld/helloworld.njk diff --git a/modules/default/newsfeed/README.md b/defaultmodules/newsfeed/README.md similarity index 100% rename from modules/default/newsfeed/README.md rename to defaultmodules/newsfeed/README.md diff --git a/defaultmodules/newsfeed/newsfeed.css b/defaultmodules/newsfeed/newsfeed.css new file mode 100644 index 0000000000..bdbcf1ca9b --- /dev/null +++ b/defaultmodules/newsfeed/newsfeed.css @@ -0,0 +1,36 @@ +.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; +} diff --git a/modules/default/newsfeed/newsfeed.js b/defaultmodules/newsfeed/newsfeed.js similarity index 75% rename from modules/default/newsfeed/newsfeed.js rename to defaultmodules/newsfeed/newsfeed.js index cbe6e3264d..b24906962c 100644 --- a/modules/default/newsfeed/newsfeed.js +++ b/defaultmodules/newsfeed/newsfeed.js @@ -74,6 +74,10 @@ Module.register("newsfeed", { this.error = null; this.activeItem = 0; this.scrollPosition = 0; + this.articleIframe = null; + this.articleContainer = null; + this.articleFrameCheckPending = false; + this.articleUnavailable = false; this.registerFeeds(); @@ -97,15 +101,60 @@ Module.register("newsfeed", { } else if (notification === "NEWSFEED_ERROR") { this.error = this.translate(payload.error_type); this.scheduleUpdateInterval(); + } else if (notification === "ARTICLE_URL_STATUS") { + if (this.config.showFullArticle) { + this.articleFrameCheckPending = false; + this.articleUnavailable = !payload.canFrame; + if (!this.articleUnavailable) { + // Article can be framed — now shift the bottom bar to allow scrolling + document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle"); + } + this.updateDom(100); + if (this.articleUnavailable) { + // Briefly show the unavailable message, then return to normal newsfeed view + setTimeout(() => { + this.resetDescrOrFullArticleAndTimer(); + this.updateDom(500); + }, 3000); + } + } + } + }, + + //Override getDom to handle the full article case with error handling + getDom () { + if (this.config.showFullArticle) { + this.activeItemHash = this.newsItems[this.activeItem]?.hash; + const wrapper = document.createElement("div"); + if (this.articleFrameCheckPending) { + // Still waiting for the server-side framing check + wrapper.innerHTML = `
${this.translate("LOADING")}
`; + } else if (this.articleUnavailable) { + wrapper.innerHTML = `
${this.translate("NEWSFEED_ARTICLE_UNAVAILABLE")}
`; + } else { + const container = document.createElement("div"); + container.className = "newsfeed-fullarticle-container"; + container.scrollTop = this.scrollPosition; + const iframe = document.createElement("iframe"); + iframe.className = "newsfeed-fullarticle"; + // Always use the direct article URL — the CORS proxy is for server-side + // RSS feed fetching, not for browser iframes. + const item = this.newsItems[this.activeItem]; + iframe.src = item ? (typeof item.url === "string" ? item.url : item.url.href) : ""; + this.articleIframe = iframe; + this.articleContainer = container; + container.appendChild(iframe); + wrapper.appendChild(container); + } + return Promise.resolve(wrapper); } + return this._super(); }, //Override fetching of template name getTemplate () { if (this.config.feedUrl) { return "oldconfig.njk"; - } else if (this.config.showFullArticle) { - return "fullarticle.njk"; } return "newsfeed.njk"; }, @@ -116,13 +165,6 @@ Module.register("newsfeed", { this.activeItem = 0; } this.activeItemCount = this.newsItems.length; - // this.config.showFullArticle is a run-time configuration, triggered by optional notifications - if (this.config.showFullArticle) { - this.activeItemHash = this.newsItems[this.activeItem]?.hash; - return { - url: this.getActiveItemURL() - }; - } if (this.error) { this.activeItemHash = undefined; return { @@ -358,6 +400,10 @@ Module.register("newsfeed", { this.isShowingDescription = this.config.showDescription; this.config.showFullArticle = false; this.scrollPosition = 0; + this.articleIframe = null; + this.articleContainer = null; + this.articleFrameCheckPending = false; + this.articleUnavailable = false; // reset bottom bar alignment document.getElementsByClassName("region bottom bar")[0].classList.remove("newsfeed-fullarticle"); if (!this.timer) { @@ -386,23 +432,26 @@ Module.register("newsfeed", { Log.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`); this.updateDom(100); } - // if "more details" is received the first time: show article summary, on second time show full article else if (notification === "ARTICLE_MORE_DETAILS") { - // full article is already showing, so scrolling down if (this.config.showFullArticle === true) { + // iframe already showing — scroll down this.scrollPosition += this.config.scrollLength; - window.scrollTo(0, this.scrollPosition); - Log.debug("[newsfeed] scrolling down"); - Log.debug(`[newsfeed] ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`); - } else { + if (this.articleContainer) this.articleContainer.scrollTop = this.scrollPosition; + Log.debug(`[newsfeed] scrolling down, offset: ${this.scrollPosition}`); + } else if (this.isShowingDescription) { + // description visible — step up to full article this.showFullArticle(); + } else { + // only title visible — show description first + this.isShowingDescription = true; + Log.debug("[newsfeed] showing article description"); + this.updateDom(100); } } else if (notification === "ARTICLE_SCROLL_UP") { if (this.config.showFullArticle === true) { - this.scrollPosition -= this.config.scrollLength; - window.scrollTo(0, this.scrollPosition); - Log.debug("[newsfeed] scrolling up"); - Log.debug(`[newsfeed] ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`); + this.scrollPosition = Math.max(0, this.scrollPosition - this.config.scrollLength); + if (this.articleContainer) this.articleContainer.scrollTop = this.scrollPosition; + Log.debug(`[newsfeed] scrolling up, offset: ${this.scrollPosition}`); } } else if (notification === "ARTICLE_LESS_DETAILS") { this.resetDescrOrFullArticleAndTimer(); @@ -416,26 +465,37 @@ Module.register("newsfeed", { this.showFullArticle(); } } else if (notification === "ARTICLE_INFO_REQUEST") { - this.sendNotification("ARTICLE_INFO_RESPONSE", { - title: this.newsItems[this.activeItem].title, - source: this.newsItems[this.activeItem].sourceTitle, - date: this.newsItems[this.activeItem].pubdate, - desc: this.newsItems[this.activeItem].description, - url: this.getActiveItemURL() - }); + const infoItem = this.newsItems[this.activeItem]; + if (infoItem) { + this.sendNotification("ARTICLE_INFO_RESPONSE", { + title: infoItem.title, + source: infoItem.sourceTitle, + date: infoItem.pubdate, + desc: infoItem.description, + url: typeof infoItem.url === "string" ? infoItem.url : infoItem.url.href + }); + } } }, showFullArticle () { - this.isShowingDescription = !this.isShowingDescription; - this.config.showFullArticle = !this.isShowingDescription; - // make bottom bar align to top to allow scrolling - if (this.config.showFullArticle === true) { - document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle"); + const item = this.newsItems[this.activeItem]; + const hasUrl = item && item.url && (typeof item.url === "string" ? item.url : item.url.href); + if (!hasUrl) { + Log.debug("[newsfeed] no article URL available, skipping full article view"); + return; } + this.isShowingDescription = false; + this.config.showFullArticle = true; + // Check server-side whether the article URL allows framing. + // The bottom bar CSS class is only added once we know the iframe will be shown. + this.articleFrameCheckPending = true; + this.articleUnavailable = false; + const rawUrl = typeof item.url === "string" ? item.url : item.url.href; + this.sendSocketNotification("CHECK_ARTICLE_URL", { url: rawUrl }); clearInterval(this.timer); this.timer = null; - Log.debug(`[newsfeed] showing ${this.isShowingDescription ? "article description" : "full article"}`); + Log.debug("[newsfeed] showing full article"); this.updateDom(100); } }); diff --git a/modules/default/newsfeed/newsfeed.njk b/defaultmodules/newsfeed/newsfeed.njk similarity index 100% rename from modules/default/newsfeed/newsfeed.njk rename to defaultmodules/newsfeed/newsfeed.njk diff --git a/defaultmodules/newsfeed/newsfeedfetcher.js b/defaultmodules/newsfeed/newsfeedfetcher.js new file mode 100644 index 0000000000..12febd6a35 --- /dev/null +++ b/defaultmodules/newsfeed/newsfeedfetcher.js @@ -0,0 +1,167 @@ +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; diff --git a/modules/default/newsfeed/node_helper.js b/defaultmodules/newsfeed/node_helper.js similarity index 59% rename from modules/default/newsfeed/node_helper.js rename to defaultmodules/newsfeed/node_helper.js index cb5af00823..cede93f698 100644 --- a/modules/default/newsfeed/node_helper.js +++ b/defaultmodules/newsfeed/node_helper.js @@ -13,6 +13,28 @@ module.exports = NodeHelper.create({ socketNotificationReceived (notification, payload) { if (notification === "ADD_FEED") { this.createFetcher(payload.feed, payload.config); + } else if (notification === "CHECK_ARTICLE_URL") { + this.checkArticleUrl(payload.url); + } + }, + + /** + * Checks whether a URL can be displayed in an iframe by inspecting + * X-Frame-Options and Content-Security-Policy headers server-side. + * @param {string} url The article URL to check. + */ + async checkArticleUrl (url) { + try { + const response = await fetch(url, { method: "HEAD" }); + const xfo = response.headers.get("x-frame-options"); + const csp = response.headers.get("content-security-policy"); + // sameorigin also blocks since the article is on a different origin than MM + const blockedByXFO = xfo && ["deny", "sameorigin"].includes(xfo.toLowerCase().trim()); + const blockedByCSP = csp && (/frame-ancestors\s+['"]?none['"]?/).test(csp); + this.sendSocketNotification("ARTICLE_URL_STATUS", { url, canFrame: !blockedByXFO && !blockedByCSP }); + } catch { + // Network error or HEAD not supported — let the browser try the iframe anyway + this.sendSocketNotification("ARTICLE_URL_STATUS", { url, canFrame: true }); } }, @@ -26,8 +48,7 @@ module.exports = NodeHelper.create({ const url = feed.url || ""; const encoding = feed.encoding || "UTF-8"; const reloadInterval = feed.reloadInterval || config.reloadInterval || 5 * 60 * 1000; - let useCorsProxy = feed.useCorsProxy; - if (useCorsProxy === undefined) useCorsProxy = true; + const useCorsProxy = feed.useCorsProxy ?? true; try { new URL(url); @@ -46,11 +67,10 @@ module.exports = NodeHelper.create({ this.broadcastFeeds(); }); - fetcher.onError((fetcher, error) => { - Log.error("Error: Could not fetch newsfeed: ", url, error); - let error_type = NodeHelper.checkFetchError(error); + fetcher.onError((fetcher, errorInfo) => { + Log.error("Error: Could not fetch newsfeed: ", fetcher.url, errorInfo.message || errorInfo); this.sendSocketNotification("NEWSFEED_ERROR", { - error_type + error_type: errorInfo.translationKey }); }); @@ -71,8 +91,8 @@ module.exports = NodeHelper.create({ */ broadcastFeeds () { const feeds = {}; - for (let f in this.fetchers) { - feeds[f] = this.fetchers[f].items(); + for (const url in this.fetchers) { + feeds[url] = this.fetchers[url].items; } this.sendSocketNotification("NEWS_ITEMS", feeds); } diff --git a/modules/default/newsfeed/oldconfig.njk b/defaultmodules/newsfeed/oldconfig.njk similarity index 100% rename from modules/default/newsfeed/oldconfig.njk rename to defaultmodules/newsfeed/oldconfig.njk diff --git a/modules/default/updatenotification/README.md b/defaultmodules/updatenotification/README.md similarity index 100% rename from modules/default/updatenotification/README.md rename to defaultmodules/updatenotification/README.md diff --git a/modules/default/updatenotification/git_helper.js b/defaultmodules/updatenotification/git_helper.js similarity index 100% rename from modules/default/updatenotification/git_helper.js rename to defaultmodules/updatenotification/git_helper.js diff --git a/modules/default/updatenotification/node_helper.js b/defaultmodules/updatenotification/node_helper.js similarity index 96% rename from modules/default/updatenotification/node_helper.js rename to defaultmodules/updatenotification/node_helper.js index b5c5fbd113..a80c09b800 100644 --- a/modules/default/updatenotification/node_helper.js +++ b/defaultmodules/updatenotification/node_helper.js @@ -2,7 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const NodeHelper = require("node_helper"); -const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`); +const defaultModules = require(`${global.root_path}/${global.defaultModulesDir}/defaultmodules`); const GitHelper = require("./git_helper"); const UpdateHelper = require("./update_helper"); diff --git a/modules/default/updatenotification/update_helper.js b/defaultmodules/updatenotification/update_helper.js similarity index 100% rename from modules/default/updatenotification/update_helper.js rename to defaultmodules/updatenotification/update_helper.js diff --git a/modules/default/updatenotification/updatenotification.css b/defaultmodules/updatenotification/updatenotification.css similarity index 100% rename from modules/default/updatenotification/updatenotification.css rename to defaultmodules/updatenotification/updatenotification.css diff --git a/modules/default/updatenotification/updatenotification.js b/defaultmodules/updatenotification/updatenotification.js similarity index 100% rename from modules/default/updatenotification/updatenotification.js rename to defaultmodules/updatenotification/updatenotification.js diff --git a/modules/default/updatenotification/updatenotification.njk b/defaultmodules/updatenotification/updatenotification.njk similarity index 100% rename from modules/default/updatenotification/updatenotification.njk rename to defaultmodules/updatenotification/updatenotification.njk diff --git a/defaultmodules/utils.js b/defaultmodules/utils.js new file mode 100644 index 0000000000..ecdb890239 --- /dev/null +++ b/defaultmodules/utils.js @@ -0,0 +1,31 @@ +/** + * 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 +}; diff --git a/modules/default/weather/README.md b/defaultmodules/weather/README.md similarity index 100% rename from modules/default/weather/README.md rename to defaultmodules/weather/README.md diff --git a/modules/default/weather/current.njk b/defaultmodules/weather/current.njk similarity index 98% rename from modules/default/weather/current.njk rename to defaultmodules/weather/current.njk index 51687231eb..b75966d3b1 100644 --- a/modules/default/weather/current.njk +++ b/defaultmodules/weather/current.njk @@ -25,7 +25,7 @@ {% if config.showHumidity === "wind" %} {{ humidity() }} {% endif %} - {% if config.showSun %} + {% if config.showSun and current.nextSunAction() %} {% if current.nextSunAction() === "sunset" %} diff --git a/modules/default/weather/forecast.njk b/defaultmodules/weather/forecast.njk similarity index 100% rename from modules/default/weather/forecast.njk rename to defaultmodules/weather/forecast.njk diff --git a/modules/default/weather/hourly.njk b/defaultmodules/weather/hourly.njk similarity index 100% rename from modules/default/weather/hourly.njk rename to defaultmodules/weather/hourly.njk diff --git a/defaultmodules/weather/node_helper.js b/defaultmodules/weather/node_helper.js new file mode 100644 index 0000000000..f9ea08c42a --- /dev/null +++ b/defaultmodules/weather/node_helper.js @@ -0,0 +1,103 @@ +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}`); + } + } +}); diff --git a/defaultmodules/weather/provider-utils.js b/defaultmodules/weather/provider-utils.js new file mode 100644 index 0000000000..5984926468 --- /dev/null +++ b/defaultmodules/weather/provider-utils.js @@ -0,0 +1,181 @@ +/** + * 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 +}; diff --git a/modules/default/weather/providers/README.md b/defaultmodules/weather/providers/README.md similarity index 54% rename from modules/default/weather/providers/README.md rename to defaultmodules/weather/providers/README.md index faa60a058a..62959756d4 100644 --- a/modules/default/weather/providers/README.md +++ b/defaultmodules/weather/providers/README.md @@ -1,3 +1,3 @@ # Weather Module Weather Provider Development Documentation -For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/development/weather-provider.html). +For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/module-development/weather-provider.html). diff --git a/defaultmodules/weather/providers/envcanada.js b/defaultmodules/weather/providers/envcanada.js new file mode 100644 index 0000000000..d09715fc19 --- /dev/null +++ b/defaultmodules/weather/providers/envcanada.js @@ -0,0 +1,450 @@ +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, /.*?]*>(.*?)<\/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>/s); + if (firstForecast) { + const forecastXml = firstForecast[1]; + const temp = this.#extract(forecastXml, /]*>(.*?)<\/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>/); + const humidex = this.#extract(xml, /]*>(.*?)<\/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>/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, /.*?.*?]*>(.*?)<\/speed>/s); + if (!windSpeed) { + windSpeed = this.#extract(forecastXml, /]*>(.*?)<\/speed>/); + } + if (windSpeed) { + current.windSpeed = (windSpeed === "calm") ? 0 : convertKmhToMs(parseFloat(windSpeed)); + } + + // Wind bearing - try currentConditions first, fallback to forecast + let windBearing = this.#extract(xml, /.*?.*?]*>(.*?)<\/bearing>/s); + if (!windBearing) { + windBearing = this.#extract(forecastXml, /]*>(.*?)<\/bearing>/); + } + if (windBearing) current.windFromDirection = parseFloat(windBearing); + + // Try icon from currentConditions first, fallback to forecast + let iconCode = this.#extract(xml, /.*?]*>(.*?)<\/iconCode>/s); + if (!iconCode) { + iconCode = this.#extract(forecastXml, /]*>(.*?)<\/iconCode>/); + } + if (iconCode) current.weatherType = this.#convertWeatherType(iconCode); + + // Humidity from currentConditions + const humidity = this.#extract(xml, /.*?]*>(.*?)<\/relativeHumidity>/s); + if (humidity) current.humidity = parseFloat(humidity); + + // Precipitation probability from forecast + const pop = this.#extract(forecastXml, /]*>(.*?)<\/pop>/); + if (pop && pop !== "") { + current.precipitationProbability = parseFloat(pop); + } + + // Sunrise/sunset (from riseSet, independent of currentConditions) + const sunriseTime = this.#extract(xml, /]*name="sunrise"[^>]*>.*?(.*?)<\/timeStamp>/s); + const sunsetTime = this.#extract(xml, /]*name="sunset"[^>]*>.*?(.*?)<\/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>/gs) || []; + + if (forecasts.length === 0) return days; + + // Get current temp + const currentTempStr = this.#extract(xml, /.*?]*>(.*?)<\/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>/); + 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>/); + 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>/); + if (temp) tempToday = parseFloat(temp); + } + + if (forecasts[index + 1]) { + const temp = this.#extract(forecasts[index + 1], /]*>(.*?)<\/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>/); + if (pop) precips.push(parseFloat(pop)); + } + + if (forecasts[index + 1]) { + const pop = this.#extract(forecasts[index + 1], /]*>(.*?)<\/pop>/); + if (pop) precips.push(parseFloat(pop)); + } + + if (precips.length > 0) { + weather.precipitationProbability = Math.max(...precips); + } + } + + #generateHourly (xml) { + const hours = []; + const hourlyMatches = xml.matchAll(/]*dateTimeUTC="([^"]*)"[^>]*>(.*?)<\/hourlyForecast>/gs); + + for (const [, dateTimeUTC, hourXML] of hourlyMatches) { + const weather = {}; + + weather.date = this.#parseECTime(dateTimeUTC); + + const temp = this.#extract(hourXML, /]*>(.*?)<\/temperature>/); + if (temp) weather.temperature = parseFloat(temp); + + const lop = this.#extract(hourXML, /]*>(.*?)<\/lop>/); + if (lop) weather.precipitationProbability = parseFloat(lop); + + const icon = this.#extract(hourXML, /]*>(.*?)<\/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; diff --git a/defaultmodules/weather/providers/openmeteo.js b/defaultmodules/weather/providers/openmeteo.js new file mode 100644 index 0000000000..7cc5f43995 --- /dev/null +++ b/defaultmodules/weather/providers/openmeteo.js @@ -0,0 +1,552 @@ +const Log = require("logger"); +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" + }; + + switch (this.config.type) { + case "hourly": + case "daily": + case "forecast": + params.forecast_days = maxNumberOfDays + 1; // Open-Meteo counts today as day 1, so maxNumberOfDays=5 needs forecast_days=6 + break; + case "current": + params.current_weather = true; + params.forecast_days = 1; + 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; diff --git a/defaultmodules/weather/providers/openweathermap.js b/defaultmodules/weather/providers/openweathermap.js new file mode 100644 index 0000000000..995855f17e --- /dev/null +++ b/defaultmodules/weather/providers/openweathermap.js @@ -0,0 +1,276 @@ +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; diff --git a/defaultmodules/weather/providers/pirateweather.js b/defaultmodules/weather/providers/pirateweather.js new file mode 100644 index 0000000000..7ea393d516 --- /dev/null +++ b/defaultmodules/weather/providers/pirateweather.js @@ -0,0 +1,270 @@ +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; diff --git a/defaultmodules/weather/providers/smhi.js b/defaultmodules/weather/providers/smhi.js new file mode 100644 index 0000000000..163d5bafde --- /dev/null +++ b/defaultmodules/weather/providers/smhi.js @@ -0,0 +1,397 @@ +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; diff --git a/defaultmodules/weather/providers/ukmetofficedatahub.js b/defaultmodules/weather/providers/ukmetofficedatahub.js new file mode 100644 index 0000000000..c0f75bc1ca --- /dev/null +++ b/defaultmodules/weather/providers/ukmetofficedatahub.js @@ -0,0 +1,329 @@ +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; diff --git a/defaultmodules/weather/providers/weatherapi.js b/defaultmodules/weather/providers/weatherapi.js new file mode 100644 index 0000000000..0ffb1821a2 --- /dev/null +++ b/defaultmodules/weather/providers/weatherapi.js @@ -0,0 +1,490 @@ +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; diff --git a/defaultmodules/weather/providers/weatherbit.js b/defaultmodules/weather/providers/weatherbit.js new file mode 100644 index 0000000000..32c261fed0 --- /dev/null +++ b/defaultmodules/weather/providers/weatherbit.js @@ -0,0 +1,292 @@ +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; diff --git a/defaultmodules/weather/providers/weatherflow.js b/defaultmodules/weather/providers/weatherflow.js new file mode 100644 index 0000000000..8a4d18daa8 --- /dev/null +++ b/defaultmodules/weather/providers/weatherflow.js @@ -0,0 +1,298 @@ +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; diff --git a/defaultmodules/weather/providers/weathergov.js b/defaultmodules/weather/providers/weathergov.js new file mode 100644 index 0000000000..5abd314683 --- /dev/null +++ b/defaultmodules/weather/providers/weathergov.js @@ -0,0 +1,416 @@ +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; + } + + #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; diff --git a/defaultmodules/weather/providers/yr.js b/defaultmodules/weather/providers/yr.js new file mode 100644 index 0000000000..0de8b51964 --- /dev/null +++ b/defaultmodules/weather/providers/yr.js @@ -0,0 +1,469 @@ +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; diff --git a/modules/default/weather/weather.css b/defaultmodules/weather/weather.css similarity index 100% rename from modules/default/weather/weather.css rename to defaultmodules/weather/weather.css diff --git a/modules/default/weather/weather.js b/defaultmodules/weather/weather.js similarity index 53% rename from modules/default/weather/weather.js rename to defaultmodules/weather/weather.js index 4b33682c21..cf0170a7b5 100644 --- a/modules/default/weather/weather.js +++ b/defaultmodules/weather/weather.js @@ -1,4 +1,4 @@ -/* global WeatherProvider, WeatherUtils, formatTime */ +/* global WeatherProvider, WeatherUtils, WeatherObject, formatTime */ Module.register("weather", { // Default module config. @@ -42,30 +42,53 @@ Module.register("weather", { colored: false, absoluteDates: false, forecastDateFormat: "ddd", // format for forecast date display, e.g., "ddd" = Mon, "dddd" = Monday, "D MMM" = 18 Oct - hourlyForecastIncrements: 1 + hourlyForecastIncrements: 1, + themeDir: "", + themeCustomScripts: [] }, - // Module properties. - weatherProvider: null, + // Module properties (all providers run server-side) + instanceId: null, + fetchedLocationName: null, + currentWeatherObject: null, + weatherForecastArray: null, + weatherHourlyArray: null, // Can be used by the provider to display location of event if nothing else is specified firstEvent: null, + getThemeDir () { + const td = this.config.themeDir.replace(/\/+$/, ""); + if (td.length > 0) { + return `${td}/`; + } else { + return ""; + } + }, + // Define required scripts. getStyles () { - return ["font-awesome.css", "weather-icons.css", "weather.css"]; + return ["font-awesome.css", "weather-icons.css", `${this.getThemeDir()}weather.css`]; }, // Return the scripts that are necessary for the weather module. getScripts () { - return ["moment.js", "weatherutils.js", "weatherobject.js", this.file("providers/overrideWrapper.js"), "weatherprovider.js", "suncalc.js", this.file(`providers/${this.config.weatherProvider.toLowerCase()}.js`)]; + // Only load client-side dependencies for rendering + // All providers run server-side via node_helper + const resArr = ["moment.js", "weatherutils.js", "weatherobject.js", "suncalc.js"]; + this.config.themeCustomScripts.forEach((element) => { + resArr.push(`${this.getThemeDir()}${element}`); + }); + return resArr; }, // Override getHeader method. getHeader () { - if (this.config.appendLocationNameToHeader && this.weatherProvider) { - if (this.data.header) return `${this.data.header} ${this.weatherProvider.fetchedLocation()}`; - else return this.weatherProvider.fetchedLocation(); + if (this.config.appendLocationNameToHeader) { + const locationName = this.fetchedLocationName || ""; + + if (this.data.header && locationName) return `${this.data.header} ${locationName}`; + else if (locationName) return locationName; } return this.data.header ? this.data.header : ""; @@ -87,17 +110,32 @@ Module.register("weather", { this.config.showHumidity = this.config.showHumidity ? "wind" : "none"; } - // Initialize the weather provider. - this.weatherProvider = WeatherProvider.initialize(this.config.weatherProvider, this); + // All providers run server-side: generate unique instance ID and initialize via node_helper + this.instanceId = `${this.identifier}_${Date.now()}`; + + if (window.initWeatherTheme) window.initWeatherTheme(this); + + Log.log(`[weather] Initializing server-side provider with instance ID: ${this.instanceId}`); - // Let the weather provider know we are starting. - this.weatherProvider.start(); + this.sendSocketNotification("INIT_WEATHER", { + instanceId: this.instanceId, + weatherProvider: this.config.weatherProvider, + ...this.config + }); + + // Server-driven fetching - no client-side scheduling needed // Add custom filters this.addFilters(); + }, - // Schedule the first update. - this.scheduleUpdate(this.config.initialLoadDelay); + // Cleanup on module hide/suspend + stop () { + if (this.instanceId) { + this.sendSocketNotification("STOP_WEATHER", { + instanceId: this.instanceId + }); + } }, // Override notification handler. @@ -121,38 +159,95 @@ Module.register("weather", { this.indoorHumidity = this.roundValue(payload); this.updateDom(300); } else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) { - this.weatherProvider.notificationReceived(payload); + // Override current weather with data from local sensors + if (this.currentWeatherObject) { + Object.assign(this.currentWeatherObject, payload); + this.updateDom(this.config.animationSpeed); + } } }, + // Handle socket notifications from node_helper + socketNotificationReceived (notification, payload) { + if (payload.instanceId !== this.instanceId) { + return; + } + + if (notification === "WEATHER_INITIALIZED") { + Log.log(`[weather] Provider initialized, location: ${payload.locationName}`); + this.fetchedLocationName = payload.locationName; + this.updateDom(); + // Server-driven fetching - HTTPFetcher will send WEATHER_DATA automatically + } else if (notification === "WEATHER_DATA") { + this.handleWeatherData(payload); + } else if (notification === "WEATHER_ERROR") { + Log.error("[weather] Error from node_helper:", payload.error); + } + }, + + handleWeatherData (payload) { + const { type, data } = payload; + + if (!data) { + return; + } + + // Convert plain objects to WeatherObject instances + switch (type) { + case "current": + this.currentWeatherObject = this.createWeatherObject(data); + break; + case "forecast": + case "daily": + this.weatherForecastArray = data.map((d) => this.createWeatherObject(d)); + break; + case "hourly": + this.weatherHourlyArray = data.map((d) => this.createWeatherObject(d)); + break; + default: + Log.warn(`Unknown weather data type: ${type}`); + break; + } + + this.updateAvailable(); + }, + + createWeatherObject (data) { + const weather = new WeatherObject(); + Object.assign(weather, { + ...data, + // Convert to moment objects for template compatibility + date: data.date ? moment(data.date) : null, + sunrise: data.sunrise ? moment(data.sunrise) : null, + sunset: data.sunset ? moment(data.sunset) : null + }); + return weather; + }, + // Select the template depending on the display type. getTemplate () { switch (this.config.type.toLowerCase()) { case "current": - return "current.njk"; + return `${this.getThemeDir()}current.njk`; case "hourly": - return "hourly.njk"; + return `${this.getThemeDir()}hourly.njk`; case "daily": case "forecast": - return "forecast.njk"; + return `${this.getThemeDir()}forecast.njk`; //Make the invalid values use the "Loading..." from forecast default: - return "forecast.njk"; + return `${this.getThemeDir()}forecast.njk`; } }, // Add all the data to the template. getTemplateData () { - const currentData = this.weatherProvider.currentWeather(); - const forecastData = this.weatherProvider.weatherForecast(); - - // Skip some hourly forecast entries if configured - const hourlyData = this.weatherProvider.weatherHourly()?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); + const hourlyData = this.weatherHourlyArray?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); return { config: this.config, - current: currentData, - forecast: forecastData, + current: this.currentWeatherObject, + forecast: this.weatherForecastArray, hourly: hourlyData, indoor: { humidity: this.indoorHumidity, @@ -164,58 +259,54 @@ Module.register("weather", { // What to do when the weather provider has new information available? updateAvailable () { Log.log("[weather] New weather information available."); - // this value was changed from 0 to 300 to stabilize weather tests: - this.updateDom(300); - this.scheduleUpdate(); + if (window.updateWeatherTheme) { + window.updateWeatherTheme(this); + } else { + this.updateDom(300); + } + + const currentWeather = this.currentWeatherObject; - if (this.weatherProvider.currentWeather()) { - this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType?.replace("-", "_") }); + if (currentWeather) { + this.sendNotification("CURRENTWEATHER_TYPE", { type: currentWeather.weatherType?.replace("-", "_") }); } const notificationPayload = { currentWeather: this.config.units === "imperial" - ? WeatherUtils.convertWeatherObjectToImperial(this.weatherProvider?.currentWeatherObject?.simpleClone()) ?? null - : this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null, + ? WeatherUtils.convertWeatherObjectToImperial(currentWeather?.simpleClone()) ?? null + : currentWeather?.simpleClone() ?? null, forecastArray: this.config.units === "imperial" - ? this.weatherProvider?.weatherForecastArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] - : this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [], + ? this.getForecastArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] + : this.getForecastArray()?.map((ar) => ar.simpleClone()) ?? [], hourlyArray: this.config.units === "imperial" - ? this.weatherProvider?.weatherHourlyArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] - : this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [], - locationName: this.weatherProvider?.fetchedLocationName, - providerName: this.weatherProvider.providerName + ? this.getHourlyArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] + : this.getHourlyArray()?.map((ar) => ar.simpleClone()) ?? [], + locationName: this.fetchedLocationName, + providerName: this.config.weatherProvider }; this.sendNotification("WEATHER_UPDATED", notificationPayload); }, - scheduleUpdate (delay = null) { - let nextLoad = this.config.updateInterval; - if (delay !== null && delay >= 0) { - nextLoad = delay; - } + getForecastArray () { + return this.weatherForecastArray; + }, - setTimeout(() => { - switch (this.config.type.toLowerCase()) { - case "current": - this.weatherProvider.fetchCurrentWeather(); - break; - case "hourly": - this.weatherProvider.fetchWeatherHourly(); - break; - case "daily": - case "forecast": - this.weatherProvider.fetchWeatherForecast(); - break; - default: - Log.error(`[weather] Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`); - } - }, nextLoad); + getHourlyArray () { + return this.weatherHourlyArray; }, + // scheduleUpdate removed - all providers use server-driven fetching via HTTPFetcher + roundValue (temperature) { + if (temperature === null || temperature === undefined) { + return ""; + } const decimals = this.config.roundTemp ? 0 : 1; const roundValue = parseFloat(temperature).toFixed(decimals); + if (roundValue === "NaN") { + return ""; + } return roundValue === "-0" ? 0 : roundValue; }, @@ -232,14 +323,18 @@ Module.register("weather", { function (value, type, valueUnit) { let formattedValue; if (type === "temperature") { - formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`; - if (this.config.degreeLabel) { - if (this.config.tempUnits === "metric") { - formattedValue += "C"; - } else if (this.config.tempUnits === "imperial") { - formattedValue += "F"; - } else { - formattedValue += "K"; + if (value === null || value === undefined) { + formattedValue = ""; + } else { + formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`; + if (this.config.degreeLabel) { + if (this.config.tempUnits === "metric") { + formattedValue += "C"; + } else if (this.config.tempUnits === "imperial") { + formattedValue += "F"; + } else { + formattedValue += "K"; + } } } } else if (type === "precip") { diff --git a/modules/default/weather/weatherobject.js b/defaultmodules/weather/weatherobject.js similarity index 92% rename from modules/default/weather/weatherobject.js rename to defaultmodules/weather/weatherobject.js index 5d6801ce13..77e88f634a 100644 --- a/modules/default/weather/weatherobject.js +++ b/defaultmodules/weather/weatherobject.js @@ -66,9 +66,13 @@ class WeatherObject { * the date from the weather-forecast. * @param {Moment} date an optional date where you want to get the next * action for. Useful only in tests, defaults to the current time. - * @returns {string} "sunset" or "sunrise" + * @returns {string|null} "sunset", "sunrise", or null if sun data unavailable */ nextSunAction (date = moment()) { + // Return null if sunrise/sunset data is unavailable + if (!this.sunrise || !this.sunset) { + return null; + } return date.isBetween(this.sunrise, this.sunset) ? "sunset" : "sunrise"; } @@ -84,6 +88,10 @@ class WeatherObject { * @returns {boolean} true if it is at dayTime */ isDayTime () { + // Default to daytime if sunrise/sunset data unavailable + if (!this.sunrise || !this.sunset) { + return true; + } const now = !this.date ? moment() : this.date; return now.isBetween(this.sunrise, this.sunset, undefined, "[]"); } diff --git a/modules/default/weather/weatherutils.js b/defaultmodules/weather/weatherutils.js similarity index 98% rename from modules/default/weather/weatherutils.js rename to defaultmodules/weather/weatherutils.js index 43a273b560..7d1436c394 100644 --- a/modules/default/weather/weatherutils.js +++ b/defaultmodules/weather/weatherutils.js @@ -25,10 +25,13 @@ const WeatherUtils = { * @returns {string} - A string with tha value and a unit postfix. */ convertPrecipitationUnit (value, valueUnit, outputUnit) { + if (value === null || value === undefined || isNaN(value)) { + return ""; + } if (valueUnit === "%") return `${value.toFixed(0)} ${valueUnit}`; let convertedValue = value; - let conversionUnit = valueUnit; + let conversionUnit; if (outputUnit === "imperial") { convertedValue = this.convertPrecipitationToInch(value, valueUnit); conversionUnit = "in"; diff --git a/eslint.config.mjs b/eslint.config.mjs index 897777943a..fcde3a6800 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 packageJson from "eslint-plugin-package-json"; +import {configs as packageJsonConfigs} from "eslint-plugin-package-json"; import playwright from "eslint-plugin-playwright"; import stylistic from "@stylistic/eslint-plugin"; -import vitest from "eslint-plugin-vitest"; +import vitest from "@vitest/eslint-plugin"; export default defineConfig([ - globalIgnores(["config/**", "modules/**/*", "!modules/default/**", "js/positions.js"]), + globalIgnores(["config/**", "modules/**/*", "js/positions.js", "tests/configs/config_variables.js"]), { files: ["**/*.js"], languageOptions: { @@ -17,7 +17,6 @@ export default defineConfig([ globals: { ...globals.browser, ...globals.node, - ...vitest.environments.env.globals, Log: "readonly", MM: "readonly", Module: "readonly", @@ -25,13 +24,11 @@ export default defineConfig([ moment: "readonly" } }, - plugins: {js, stylistic, vitest}, - extends: [importX.recommended, vitest.configs.recommended, "js/recommended", jsdocPlugin.configs["flat/recommended"], "stylistic/all"], + extends: [importX.recommended, js.configs.recommended, jsdocPlugin.configs["flat/recommended"], stylistic.configs.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"], @@ -53,29 +50,12 @@ export default defineConfig([ "@stylistic/space-before-function-paren": ["error", "always"], "@stylistic/spaced-comment": "off", "dot-notation": "error", - eqeqeq: "error", + eqeqeq: ["error", "always", {null: "ignore"}], "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", @@ -94,6 +74,7 @@ export default defineConfig([ "object-shorthand": ["error", "methods"], "one-var": "off", "prefer-template": "error", + "require-await": "error", "sort-keys": "off" } }, @@ -108,8 +89,7 @@ export default defineConfig([ }, { files: ["**/package.json"], - plugins: {packageJson}, - extends: ["packageJson/recommended"] + extends: [packageJsonConfigs.recommended] }, { files: ["**/*.mjs"], @@ -120,8 +100,7 @@ export default defineConfig([ }, sourceType: "module" }, - plugins: {js, stylistic}, - extends: [importX.recommended, "js/all", "stylistic/all"], + extends: [importX.recommended, js.configs.all, stylistic.configs.all], rules: { "@stylistic/array-element-newline": "off", "@stylistic/indent": ["error", "tab"], @@ -135,6 +114,45 @@ 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: { @@ -145,6 +163,13 @@ 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" } } diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000000..72629703a2 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/index.html b/index.html index de29247dab..af9df8560d 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ - + @@ -44,14 +44,14 @@ - - - + + + diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index ee51664fc9..0000000000 --- a/jest.config.js +++ /dev/null @@ -1,41 +0,0 @@ -const aliasMapper = { - logger: "/js/logger.js" -}; - -const config = { - verbose: true, - testTimeout: 20000, - testSequencer: "/tests/utils/test_sequencer.js", - projects: [ - { - displayName: "unit", - globalSetup: "/tests/unit/helpers/global-setup.js", - moduleNameMapper: aliasMapper, - testMatch: ["**/tests/unit/**/*.[jt]s?(x)"], - testPathIgnorePatterns: ["/tests/unit/mocks", "/tests/unit/helpers"] - }, - { - displayName: "electron", - testMatch: ["**/tests/electron/**/*.[jt]s?(x)"], - moduleNameMapper: aliasMapper, - testPathIgnorePatterns: ["/tests/electron/helpers"] - }, - { - displayName: "e2e", - testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"], - modulePaths: ["/js/"], - moduleNameMapper: aliasMapper, - testPathIgnorePatterns: ["/tests/e2e/helpers", "/tests/e2e/mocks"] - } - ], - collectCoverageFrom: [ - "/clientonly/**/*.js", - "/js/**/*.js", - "/modules/default/**/*.js", - "/serveronly/**/*.js" - ], - coverageReporters: ["lcov", "text"], - coverageProvider: "v8" -}; - -module.exports = config; diff --git a/js/alias-resolver.js b/js/alias-resolver.js index 9af170a204..602e06677e 100644 --- a/js/alias-resolver.js +++ b/js/alias-resolver.js @@ -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("module"); +const Module = require("node:module"); const root = path.join(__dirname, ".."); diff --git a/js/app.js b/js/app.js index 43e1119bc9..04bda1221e 100644 --- a/js/app.js +++ b/js/app.js @@ -3,19 +3,19 @@ require("./alias-resolver"); const fs = require("node:fs"); const path = require("node:path"); -const envsub = require("envsub"); +const Spawn = require("node:child_process").spawn; 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 { getEnvVarsAsObj, getConfigFilePath } = require("#server_functions"); + +const Server = require("./server"); +const Utils = require("./utils"); + +const { getEnvVarsAsObj } = 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. -Utils.logSystemInformation(global.version); +Spawn("node ./js/systeminformation.js", { env: { ...process.env, ELECTRON_VERSION: `${process.versions.electron}` }, cwd: this.root_path, shell: true, detached: true, stdio: "inherit" }); if (process.env.MM_CONFIG_FILE) { global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, ""); @@ -56,122 +56,8 @@ process.on("uncaughtException", function (err) { function App () { let nodeHelpers = []; let httpServer; - - /** - * Loads the config file. Combines it with the defaults and returns the config - * @async - * @returns {Promise} 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.`); - } - } - } - } + let defaultModules; + let env; /** * Loads a specific module. @@ -180,11 +66,10 @@ 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}/modules/default/`, module); + const defaultModuleFolder = path.resolve(`${global.root_path}/${global.defaultModulesDir}/`, module); if (!global.mmTestMode) { moduleFolder = defaultModuleFolder; } else { @@ -288,10 +173,26 @@ function App () { * @returns {Promise} the config used */ this.start = async function () { - config = await loadConfig(); + const configObj = Utils.loadConfig(); + config = configObj.fullConf; + Utils.checkConfigFile(configObj); + + global.defaultModulesDir = config.defaultModulesDir; + defaultModules = require(`${global.root_path}/${global.defaultModulesDir}/defaultmodules`); 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(); @@ -316,7 +217,7 @@ function App () { await loadModules(modules); - httpServer = new Server(config); + httpServer = new Server(configObj); const { app, io } = await httpServer.open(); Log.log("Server started ..."); diff --git a/js/check_config.js b/js/check_config.js index f2c9b2b7a2..3ed042c10b 100644 --- a/js/check_config.js +++ b/js/check_config.js @@ -2,154 +2,13 @@ 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 { - checkConfigFile(); + Utils.checkConfigFile(); } catch (error) { const message = error && error.message ? error.message : error; Log.error(`Unexpected error: ${message}`); diff --git a/js/defaults.js b/js/defaults.js index 469a172b37..bd590894cb 100644 --- a/js/defaults.js +++ b/js/defaults.js @@ -9,7 +9,6 @@ const defaults = { address: address, port: port, basePath: "/", - kioskmode: false, electronOptions: {}, ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"], @@ -18,8 +17,10 @@ const defaults = { timeFormat: 24, units: "metric", zoom: 1, - customCss: "css/custom.css", + customCss: "config/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 }, diff --git a/js/deprecated.js b/js/deprecated.js index 10cf7ae044..8c8ef3fc54 100644 --- a/js/deprecated.js +++ b/js/deprecated.js @@ -1,4 +1,4 @@ module.exports = { - configs: ["kioskmode"], + configs: [], clock: ["secondsColor"] }; diff --git a/js/electron.js b/js/electron.js index 752f511e69..9b4465a1b9 100644 --- a/js/electron.js +++ b/js/electron.js @@ -48,7 +48,7 @@ function createWindow () { let electronOptionsDefaults = { width: electronSize.width, height: electronSize.height, - icon: "mm2.png", + icon: "favicon.svg", x: 0, y: 0, darkTheme: true, @@ -60,19 +60,11 @@ function createWindow () { backgroundColor: "#000000" }; - /* - * 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; - } + electronOptionsDefaults.show = false; + electronOptionsDefaults.frame = false; + electronOptionsDefaults.transparent = true; + electronOptionsDefaults.hasShadow = false; + electronOptionsDefaults.fullscreen = true; const electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions); @@ -132,22 +124,6 @@ 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; diff --git a/js/http_fetcher.js b/js/http_fetcher.js new file mode 100644 index 0000000000..f72b4a3be9 --- /dev/null +++ b/js/http_fetcher.js @@ -0,0 +1,343 @@ +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; diff --git a/js/loader.js b/js/loader.js index b3cfd71910..53283d59b6 100644 --- a/js/loader.js +++ b/js/loader.js @@ -17,7 +17,8 @@ const Loader = (function () { const getEnvVarsFromConfig = function () { return { modulesDir: config.foreignModulesDir || "modules", - customCss: config.customCss || "css/custom.css" + defaultModulesDir: config.defaultModulesDir || "defaultmodules", + customCss: config.customCss || "config/custom.css" }; }; @@ -103,7 +104,7 @@ const Loader = (function () { let moduleFolder = `${envVars.modulesDir}/${module}`; if (defaultModules.indexOf(moduleName) !== -1) { - const defaultModuleFolder = `modules/default/${module}`; + const defaultModuleFolder = `${envVars.defaultModulesDir}/${module}`; if (window.name !== "jsdom") { moduleFolder = defaultModuleFolder; } else { @@ -192,7 +193,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 = async function (fileName) { + const loadFile = function (fileName) { const extension = fileName.slice((Math.max(0, fileName.lastIndexOf(".")) || Infinity) + 1); let script, stylesheet; @@ -261,15 +262,15 @@ const Loader = (function () { /** * Load a file (script or stylesheet). - * Prevent double loading and search for files in the vendor folder. + * Prevent double loading and search for files defined in js/vendor.js. * @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 */ - async loadFileForModule (fileName, module) { + loadFileForModule (fileName, module) { if (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) { Log.log(`File already loaded: ${fileName}`); - return; + return Promise.resolve(); } if (fileName.indexOf("http://") === 0 || fileName.indexOf("https://") === 0 || fileName.indexOf("/") !== -1) { @@ -280,8 +281,8 @@ const Loader = (function () { } if (vendor[fileName] !== undefined) { - // This file is available in the vendor folder. - // Load it from this vendor folder. + // This file is defined in js/vendor.js. + // Load it from its location. loadedFiles.push(fileName.toLowerCase()); return loadFile(`${vendor[fileName]}`); } diff --git a/js/logger.js b/js/logger.js index a31b22a0ad..b4b552288b 100644 --- a/js/logger.js +++ b/js/logger.js @@ -1,131 +1,114 @@ -// This logger is very simple, but needs to be extended. -(function (root, factory) { - if (typeof exports === "object") { +// Logger for MagicMirror² — works both in Node.js (CommonJS) and the browser (global). +(function () { + if (typeof module !== "undefined") { if (process.env.mmTestMode !== "true") { const { styleText } = require("node:util"); - // 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; + 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}]`); } - 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-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"; + // Node, CommonJS + module.exports = makeLogger(); } else { - // in browser and not running with jsdom - enableLog = typeof window === "object" && window.name !== "jsdom"; + // Browser globals + window.Log = makeLogger(); } - 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 () {} - }; + /** + * 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"; - 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 () {} - }; + let logLevel; - logLevel.setLogLevel = function () {}; - } + 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) + }; - return logLevel; -})); + // 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; + } +}()); diff --git a/js/main.js b/js/main.js index 6f7f65f8b2..359f71df0e 100644 --- a/js/main.js +++ b/js/main.js @@ -1,4 +1,4 @@ -/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */ +/* global Loader, defaults, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */ const MM = (function () { let modules = []; @@ -470,17 +470,15 @@ const MM = (function () { }; /** - * Loads the core config and combines it with the system defaults. + * Loads the core config from the server (already combined with the system defaults). */ - 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; + 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); } - - config = Object.assign({}, defaults, config); }; /** @@ -582,7 +580,7 @@ const MM = (function () { */ async init () { Log.info("Initializing MagicMirror²."); - loadConfig(); + await loadConfig(); Log.setLogLevel(config.logLevel); diff --git a/js/module.js b/js/module.js index a600c50cd8..f38d808995 100644 --- a/js/module.js +++ b/js/module.js @@ -1,4 +1,4 @@ -/* global Class, cloneObject, Loader, MMSocket, nunjucks, Translator */ +/* global Class, cloneObject, Loader, MMSocket, nunjucks */ /* * Module Blueprint. @@ -43,7 +43,7 @@ const Module = Class.extend({ /** * Called when the module is started. */ - async start () { + start () { Log.info(`Starting module: ${this.name}`); }, diff --git a/js/module_functions.js b/js/module_functions.js deleted file mode 100644 index c7ecc074a2..0000000000 --- a/js/module_functions.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * 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 }; diff --git a/js/node_helper.js b/js/node_helper.js index cbe30edda0..8910699930 100644 --- a/js/node_helper.js +++ b/js/node_helper.js @@ -1,6 +1,7 @@ const express = require("express"); const Log = require("logger"); const Class = require("./class"); +const { replaceSecretPlaceholder } = require("#server_functions"); const NodeHelper = Class.extend({ init () { @@ -27,7 +28,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}`); @@ -88,7 +89,17 @@ const NodeHelper = Class.extend({ io.of(this.name).on("connection", (socket) => { // register catch all. socket.onAny((notification, payload) => { - this.socketNotificationReceived(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); + } }); }); } diff --git a/js/server.js b/js/server.js index f6105936f1..9922958ca9 100644 --- a/js/server.js +++ b/js/server.js @@ -6,18 +6,20 @@ 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(`${__dirname}/ip_access_control`); +const { ipAccessControl } = require("./ip_access_control"); -const vendor = require(`${__dirname}/vendor`); +const vendor = require("./vendor"); + +const { getHtml, getVersion, getEnvVars, cors } = require("#server_functions"); /** * Server - * @param {object} config The MM config + * @param {object} configObj The MM config full and redacted * @class */ -function Server (config) { +function Server (configObj) { + const config = configObj.fullConf; const app = express(); const port = process.env.MM_PORT || config.port; const serverSockets = new Set(); @@ -89,7 +91,13 @@ function Server (config) { app.use(helmet(config.httpHeaders)); app.use("/js", express.static(__dirname)); - let directories = ["/config", "/css", "/modules", "/node_modules/animate.css", "/node_modules/@fontsource", "/node_modules/@fortawesome", "/translations", "/tests/configs", "/tests/mocks"]; + if (config.hideConfigSecrets) { + app.get("/config/config.env", (req, res) => { + res.status(404).send("\n\n\n\nError\n\n\n
Cannot GET /config/config.env
\n\n"); + }); + } + + let directories = ["/config", "/css", "/favicon.svg", "/defaultmodules", "/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]}`); @@ -99,12 +107,22 @@ function Server (config) { 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)); diff --git a/js/server_functions.js b/js/server_functions.js index a7eba8c358..063e9cfe35 100644 --- a/js/server_functions.js +++ b/js/server_functions.js @@ -5,21 +5,23 @@ const Log = require("logger"); const startUp = new Date(); /** - * Gets the config. + * Gets the startup time. * @param {Request} req - the request * @param {Response} res - the result */ -function getConfig (req, res) { - res.send(config); +function getStartup (req, res) { + res.send(startUp); } /** - * Gets the startup time. - * @param {Request} req - the request - * @param {Response} res - the result + * 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 getStartup (req, res) { - res.send(startUp); +function replaceSecretPlaceholder (input) { + return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (match, group) => { + return process.env[group]; + }); } /** @@ -44,6 +46,11 @@ 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); @@ -55,8 +62,8 @@ async function cors (req, res) { const headerValue = response.headers.get(header); if (header) res.set(header, headerValue); } - const data = await response.text(); - res.send(data); + const arrayBuffer = await response.arrayBuffer(); + res.send(Buffer.from(arrayBuffer)); } else { throw new Error(`Response status: ${response.status}`); } @@ -118,12 +125,6 @@ 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); } @@ -162,7 +163,7 @@ function getUserAgent () { * @returns {object} environment variables key: values */ function getEnvVarsAsObj () { - const obj = { modulesDir: `${config.foreignModulesDir}`, customCss: `${config.customCss}` }; + const obj = { modulesDir: `${config.foreignModulesDir}`, defaultModulesDir: `${config.defaultModulesDir}`, customCss: `${config.customCss}` }; if (process.env.MM_MODULES_DIR) { obj.modulesDir = process.env.MM_MODULES_DIR.replace(`${global.root_path}/`, ""); } @@ -201,4 +202,4 @@ function getConfigFilePath () { return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`); } -module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath }; +module.exports = { cors, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath, replaceSecretPlaceholder }; diff --git a/js/systeminformation.js b/js/systeminformation.js new file mode 100644 index 0000000000..e8f6b9732b --- /dev/null +++ b/js/systeminformation.js @@ -0,0 +1,39 @@ +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(); diff --git a/js/utils.js b/js/utils.js index 54a31da1c3..f1c80ba2d0 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,84 +1,293 @@ -const os = require("node:os"); const fs = require("node:fs"); -const si = require("systeminformation"); -const Log = require("logger"); +const { loadEnvFile } = require("node:process"); const modulePositions = []; // will get list from index.html const regionRegEx = /"region ([^"]*)/i; const indexFileName = "index.html"; const discoveredPositionsJSFilename = "js/positions.js"; -module.exports = { +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"); + +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); + } + } + }); + 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; +}; + +/** + * 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`); + + // 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 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); - async logSystemInformation (mirrorVersion) { 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: ${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); - - // Return is currently only for tests - return systemDataString; + const cfg = `let config = { basePath: "${configObj.fullConf.basePath}"};`; + fs.writeFileSync(`${global.root_path}/config/basepath.js`, cfg, "utf-8"); } catch (error) { - Log.error(error); + Log.error(`Could not write config/basepath.js file: ${error.message}`); + } + + return configObj; + + } 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 {}; +}; + +/** + * 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 + } + }, + 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(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}`; } - }, - - // return all available module positions - getAvailableModulePositions () { - return modulePositions; - }, - - // return if position is on modulePositions Array (true/false) - moduleHasValidPosition (position) { - if (this.getAvailableModulePositions().indexOf(position) === -1) return false; - return true; - }, - - 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); - } + 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"] } - }); - 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"); + } + }; + + // 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(", ")}`); + } } } - // return the list to the caller - return modulePositions; + } 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); } }; + +module.exports = { loadConfig, getModulePositions, moduleHasValidPosition, getAvailableModulePositions, checkConfigFile }; diff --git a/mm2.png b/mm2.png deleted file mode 100644 index 5be0b24be2..0000000000 Binary files a/mm2.png and /dev/null differ diff --git a/modules/.gitkeep b/modules/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/default/calendar/calendarfetcher.js b/modules/default/calendar/calendarfetcher.js deleted file mode 100644 index 2b29bfc705..0000000000 --- a/modules/default/calendar/calendarfetcher.js +++ /dev/null @@ -1,222 +0,0 @@ -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; diff --git a/modules/default/calendar/calendarfetcherutils.js b/modules/default/calendar/calendarfetcherutils.js deleted file mode 100644 index 61766c9297..0000000000 --- a/modules/default/calendar/calendarfetcherutils.js +++ /dev/null @@ -1,408 +0,0 @@ -/** - * @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; -} diff --git a/modules/default/newsfeed/fullarticle.njk b/modules/default/newsfeed/fullarticle.njk deleted file mode 100644 index fbd6e437a1..0000000000 --- a/modules/default/newsfeed/fullarticle.njk +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/modules/default/newsfeed/newsfeed.css b/modules/default/newsfeed/newsfeed.css deleted file mode 100644 index 2c690a48e2..0000000000 --- a/modules/default/newsfeed/newsfeed.css +++ /dev/null @@ -1,24 +0,0 @@ -iframe.newsfeed-fullarticle { - width: 100vw; - - /* very large height value to allow scrolling */ - height: 3000px; - top: 0; - left: 0; - border: none; - z-index: 1; -} - -.region.bottom.bar.newsfeed-fullarticle { - bottom: inherit; - top: -90px; -} - -.newsfeed-list { - list-style: none; -} - -.newsfeed-list li { - text-align: justify; - margin-bottom: 0.5em; -} diff --git a/modules/default/newsfeed/newsfeedfetcher.js b/modules/default/newsfeed/newsfeedfetcher.js deleted file mode 100644 index d06f8cf332..0000000000 --- a/modules/default/newsfeed/newsfeedfetcher.js +++ /dev/null @@ -1,175 +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 NodeHelper = require("node_helper"); -const { getUserAgent } = require("#server_functions"); -const { scheduleTimer } = require("#module_functions"); - -/** - * Responsible for requesting an update on the set interval and broadcasting the data. - * @param {string} url URL of the news feed. - * @param {number} reloadInterval Reload interval in milliseconds. - * @param {string} encoding Encoding of the feed. - * @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. - * @class - */ -const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy) { - let reloadTimer = null; - let items = []; - let reloadIntervalMS = reloadInterval; - - let fetchFailedCallback = function () {}; - let itemsReceivedCallback = function () {}; - - if (reloadIntervalMS < 1000) { - reloadIntervalMS = 1000; - } - - /* private methods */ - - /** - * Request the new items. - */ - const fetchNews = () => { - clearTimeout(reloadTimer); - reloadTimer = null; - 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" } - ] - }); - - items.push({ - title: title, - description: description, - pubdate: pubdate, - url: url, - useCorsProxy: useCorsProxy, - hash: crypto.createHash("sha256").update(`${pubdate} :: ${title} :: ${url}`).digest("hex") - }); - } else if (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) => { - fetchFailedCallback(this, error); - scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews); - }); - - //"end" event is not broadcast if the feed is empty but "finish" is used for both - parser.on("finish", () => { - scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews); - }); - - parser.on("ttl", (minutes) => { - try { - // 86400000 = 24 hours is mentioned in the docs as maximum value: - const ttlms = Math.min(minutes * 60 * 1000, 86400000); - if (ttlms > reloadIntervalMS) { - reloadIntervalMS = ttlms; - Log.info(`reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`); - } - } catch (error) { - Log.warn(`feed ttl is no valid integer=${minutes} for url ${url}`); - } - }); - - const headers = { - "User-Agent": getUserAgent(), - "Cache-Control": "max-age=0, no-cache, no-store, must-revalidate", - Pragma: "no-cache" - }; - - fetch(url, { headers: headers }) - .then(NodeHelper.checkFetchStatus) - .then((response) => { - let nodeStream; - if (response.body instanceof stream.Readable) { - nodeStream = response.body; - } else { - nodeStream = stream.Readable.fromWeb(response.body); - } - nodeStream.pipe(iconv.decodeStream(encoding)).pipe(parser); - }) - .catch((error) => { - fetchFailedCallback(this, error); - scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews); - }); - }; - - /* public methods */ - - /** - * Update the reload interval, but only if we need to increase the speed. - * @param {number} interval Interval for the update in milliseconds. - */ - this.setReloadInterval = function (interval) { - if (interval > 1000 && interval < reloadIntervalMS) { - reloadIntervalMS = interval; - } - }; - - /** - * Initiate fetchNews(); - */ - this.startFetch = function () { - fetchNews(); - }; - - /** - * Broadcast the existing items. - */ - this.broadcastItems = function () { - if (items.length <= 0) { - Log.info("No items to broadcast yet."); - return; - } - Log.info(`Broadcasting ${items.length} items.`); - itemsReceivedCallback(this); - }; - - this.onReceive = function (callback) { - itemsReceivedCallback = callback; - }; - - this.onError = function (callback) { - fetchFailedCallback = callback; - }; - - this.url = function () { - return url; - }; - - this.items = function () { - return items; - }; -}; - -module.exports = NewsfeedFetcher; diff --git a/modules/default/utils.js b/modules/default/utils.js deleted file mode 100644 index d9eab5c57e..0000000000 --- a/modules/default/utils.js +++ /dev/null @@ -1,183 +0,0 @@ -/** - * A function to make HTTP requests via the server to avoid CORS-errors. - * @param {string} url the url to fetch from - * @param {string} type what content-type to expect in the response, can be "json" or "xml" - * @param {boolean} useCorsProxy A flag to indicate - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @param {string} basePath The base path, default is "/" - * @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property). - */ -async function performWebRequest (url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined, basePath = "/") { - const request = {}; - let requestUrl; - if (useCorsProxy) { - requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders, basePath); - } else { - requestUrl = url; - request.headers = getHeadersToSend(requestHeaders); - } - - try { - const response = await fetch(requestUrl, request); - if (response.ok) { - const data = await response.text(); - - if (type === "xml") { - return new DOMParser().parseFromString(data, "text/html"); - } else { - if (!data || !data.length > 0) return undefined; - - const dataResponse = JSON.parse(data); - if (!dataResponse.headers) { - dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response); - } - return dataResponse; - } - } else { - throw new Error(`Response status: ${response.status}`); - } - } catch (error) { - Log.error(`Error fetching data from ${url}: ${error}`); - return undefined; - } -} - -/** - * Gets a URL that will be used when calling the CORS-method on the server. - * @param {string} url the url to fetch from - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @param {string} basePath The base path, default is "/" - * @returns {string} to be used as URL when calling CORS-method on server. - */ -const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders, basePath = "/") { - if (!url || url.length < 1) { - throw new Error(`Invalid URL: ${url}`); - } else { - let corsUrl = `${location.protocol}//${location.host}${basePath}cors?`; - - const requestHeaderString = getRequestHeaderString(requestHeaders); - if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`; - - const expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders); - if (requestHeaderString && expectedResponseHeadersString) { - corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`; - } else if (expectedResponseHeadersString) { - corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`; - } - - if (requestHeaderString || expectedResponseHeadersString) { - return `${corsUrl}&url=${url}`; - } - return `${corsUrl}url=${url}`; - } -}; - -/** - * Gets the part of the CORS URL that represents the HTTP headers to send. - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @returns {string} to be used as request-headers component in CORS URL. - */ -const getRequestHeaderString = function (requestHeaders) { - let requestHeaderString = ""; - if (requestHeaders) { - for (const header of requestHeaders) { - if (requestHeaderString.length === 0) { - requestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`; - } else { - requestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`; - } - } - return requestHeaderString; - } - return undefined; -}; - -/** - * Gets headers and values to attach to the web request. - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @returns {object} An object specifying name and value of the headers. - */ -const getHeadersToSend = (requestHeaders) => { - const headersToSend = {}; - if (requestHeaders) { - for (const header of requestHeaders) { - headersToSend[header.name] = header.value; - } - } - - return headersToSend; -}; - -/** - * Gets the part of the CORS URL that represents the expected HTTP headers to receive. - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @returns {string} to be used as the expected HTTP-headers component in CORS URL. - */ -const getExpectedResponseHeadersString = function (expectedResponseHeaders) { - let expectedResponseHeadersString = ""; - if (expectedResponseHeaders) { - for (const header of expectedResponseHeaders) { - if (expectedResponseHeadersString.length === 0) { - expectedResponseHeadersString = `${header}`; - } else { - expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`; - } - } - return expectedResponseHeaders; - } - return undefined; -}; - -/** - * Gets the values for the expected headers from the response. - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @param {Response} response the HTTP response - * @returns {string} to be used as the expected HTTP-headers component in CORS URL. - */ -const getHeadersFromResponse = (expectedResponseHeaders, response) => { - const responseHeaders = []; - - if (expectedResponseHeaders) { - for (const header of expectedResponseHeaders) { - const headerValue = response.headers.get(header); - responseHeaders.push({ name: header, value: headerValue }); - } - } - - return responseHeaders; -}; - -/** - * 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 = { - performWebRequest, - formatTime -}; diff --git a/modules/default/weather/providers/envcanada.js b/modules/default/weather/providers/envcanada.js deleted file mode 100644 index d3d7cd5d67..0000000000 --- a/modules/default/weather/providers/envcanada.js +++ /dev/null @@ -1,620 +0,0 @@ -/* global WeatherProvider, WeatherObject, WeatherUtils */ - -/* - * This class is a provider for Environment Canada MSC Datamart - * Note that this is only for Canadian locations and does not require an API key (access is anonymous) - * - * EC Documentation at following links: - * https://dd.weather.gc.ca/citypage_weather/schema/ - * https://eccc-msc.github.io/open-data/msc-datamart/readme_en/ - * - * This module supports Canadian locations only and requires 2 additional config parameters: - * - * siteCode - the city/town unique identifier for which weather is to be displayed. Format is 's0000000'. - * - * provCode - the 2-character province code for the selected city/town. - * - * Example: for Toronto, Ontario, the following parameters would be used - * - * siteCode: 's0000458', - * provCode: 'ON' - * - * To determine the siteCode and provCode values for a Canadian city/town, look at the Environment Canada document - * at https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv (or site_list_fr.csv). There you will find a table - * with locations you can search under column B (English Names), with the corresponding siteCode under - * column A (Codes) and provCode under column C (Province). - * - * Acknowledgement: Some logic and code for parsing Environment Canada web pages is based on material from MMM-EnvCanada - * - * License to use Environment Canada (EC) data is detailed here: - * https://eccc-msc.github.io/open-data/licence/readme_en/ - */ -WeatherProvider.register("envcanada", { - // Set the name of the provider for debugging and alerting purposes (eg. provide eye-catcher) - providerName: "Environment Canada", - - // Set the default config properties that is specific to this provider - defaults: { - useCorsProxy: true, - siteCode: "s1234567", - provCode: "ON" - }, - - /* - * Set config values (equates to weather module config values). Also set values pertaining to caching of - * Today's temperature forecast (for use in the Forecast functions below) - */ - setConfig (config) { - this.config = config; - - this.todayTempCacheMin = 0; - this.todayTempCacheMax = 0; - this.todayCached = false; - this.cacheCurrentTemp = 999; - this.lastCityPageCurrent = " "; - this.lastCityPageForecast = " "; - this.lastCityPageHourly = " "; - }, - - /* - * Called when the weather provider is started - */ - start () { - Log.info(`[weatherprovider.envcanada] ${this.providerName} started.`); - this.setFetchedLocation(this.config.location); - }, - - /* - * Override the fetchCurrentWeather method to query EC and construct a Current weather object - */ - fetchCurrentWeather () { - this.fetchCommon("Current"); - }, - - /* - * Override the fetchWeatherForecast method to query EC and construct Forecast/Daily weather objects - */ - fetchWeatherForecast () { - - this.fetchCommon("Forecast"); - - }, - - /* - * Override the fetchWeatherHourly method to query EC and construct Hourly weather objects - */ - fetchWeatherHourly () { - this.fetchCommon("Hourly"); - }, - - /* - * Because the process to fetch weather data is virtually the same for Current, Forecast/Daily, and Hourly weather, - * a common module is used to access the EC weather data. The only customization (based on the caller of this routine) - * is how the data will be parsed to satisfy the Weather module config in Config.js - * - * Accessing EC weather data is accomplished in 2 steps: - * - * 1. Query the MSC Datamart Index page, which returns a list of all the filenames for all the cities that have - * weather data currently available. - * - * 2. With the city filename identified, build the appropriate URL and get the weather data (XML document) for the - * city specified in the Weather module Config information - */ - fetchCommon (target) { - const forecastURL = this.getUrl(); // Get the appropriate URL for the MSC Datamart Index page - - Log.debug(`[weatherprovider.envcanada] ${target} Index url: ${forecastURL}`); - - this.fetchData(forecastURL, "xml") // Query the Index page URL - .then((indexData) => { - if (!indexData) { - // Did not receive usable new data. - Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable index data`); - this.updateAvailable(); // If there were issues, update anyways to reset timer - return; - } - - /** - * With the Index page read, we must locate the filename/link for the specified city (aka Sitecode). - * This is done by building the city filename and searching for it on the Index page. Once found, - * extract the full filename (a unique name that includes dat/time, filename, etc.) and then add it - * to the Index page URL to create the proper URL pointing to the city's weather data. Finally, read the - * URL to pull in the city's XML document so that weather data can be parsed and displayed. - */ - - let forecastFile = ""; - let forecastFileURL = ""; - const fileSuffix = `${this.config.siteCode}_en.xml`; // Build city filename - const nextFile = indexData.body.innerHTML.split(fileSuffix); // Find filename on Index page - - if (nextFile.length > 1) { // Parse out the full unique file city filename - // Find the last occurrence - forecastFile = nextFile[nextFile.length - 2].slice(-41) + fileSuffix; - forecastFileURL = forecastURL + forecastFile; // Create full URL to the city's weather data - } - - Log.debug(`[weatherprovider.envcanada] ${target} Citypage url: ${forecastFileURL}`); - - /* - * If the Citypage filename has not changed since the last Weather refresh, the forecast has not changed and - * and therefore we can skip reading the Citypage URL. - */ - - if (target === "Current" && this.lastCityPageCurrent === forecastFileURL) { - Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); - this.updateAvailable(); // Update anyways to reset refresh timer - return; - } - - if (target === "Forecast" && this.lastCityPageForecast === forecastFileURL) { - Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); - this.updateAvailable(); // Update anyways to reset refresh timer - return; - } - - if (target === "Hourly" && this.lastCityPageHourly === forecastFileURL) { - Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); - this.updateAvailable(); // Update anyways to reset refresh timer - return; - } - - this.fetchData(forecastFileURL, "xml") // Read city's URL to get weather data - .then((cityData) => { - if (!cityData) { - // Did not receive usable new data. - Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable citypage data`); - return; - } - - /* - * With the city's weather data read, parse the resulting XML document for the appropriate weather data - * elements to create a weather object. Next, set Weather modules details from that object. - */ - Log.debug(`[weatherprovider.envcanada] ${target} - Citypage has been read and will be processed for updates`); - - if (target === "Current") { - const currentWeather = this.generateWeatherObjectFromCurrentWeather(cityData); - this.setCurrentWeather(currentWeather); - this.lastCityPageCurrent = forecastFileURL; - } - - if (target === "Forecast") { - const forecastWeather = this.generateWeatherObjectsFromForecast(cityData); - this.setWeatherForecast(forecastWeather); - this.lastCityPageForecast = forecastFileURL; - } - - if (target === "Hourly") { - const hourlyWeather = this.generateWeatherObjectsFromHourly(cityData); - this.setWeatherHourly(hourlyWeather); - this.lastCityPageHourly = forecastFileURL; - } - }) - .catch(function (cityRequest) { - Log.info(`[weatherprovider.envcanada] ${target} - could not load citypage data from: ${forecastFileURL}`); - }) - .finally(() => this.updateAvailable()); // Update no matter what to reset weather refresh timer - }) - .catch(function (indexRequest) { - Log.error(`[weatherprovider.envcanada] ${target} - could not load index data ... `, indexRequest); - this.updateAvailable(); // If there were issues, update anyways to reset timer - }); - }, - - /* - * Build the EC Index page URL based on current GMT hour. The Index page will provide a list of links for each city - * that will, in turn, provide actual weather data. The URL is comprised of 3 parts: - * - * Fixed value + Prov code specified in Weather module Config.js + current hour as GMT - */ - getUrl () { - let forecastURL = `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}`; - const hour = this.getCurrentHourGMT(); - forecastURL += `/${hour}/`; - return forecastURL; - }, - - /* - * Get current hour-of-day in GMT context - */ - getCurrentHourGMT () { - const now = new Date(); - return now.toISOString().substring(11, 13); // "HH" in GMT - }, - - /* - * Generate a WeatherObject based on current EC weather conditions - */ - generateWeatherObjectFromCurrentWeather (ECdoc) { - const currentWeather = new WeatherObject(); - - /* - * There are instances where EC will update weather data and current temperature will not be - * provided. While this is a defect in the EC systems, we need to accommodate to avoid a current temp - * of NaN being displayed. Therefore... whenever we get a valid current temp from EC, we will cache - * the value. Whenever EC data is missing current temp, we will provide the cached value - * instead. This is reasonable since the cached value will typically be accurate within the previous - * hour. The only time this does not work as expected is when MM is restarted and the first query to - * EC finds no current temp. In this scenario, MM will end up displaying a current temp of null; - */ - if (ECdoc.querySelector("siteData currentConditions temperature").textContent) { - currentWeather.temperature = ECdoc.querySelector("siteData currentConditions temperature").textContent; - this.cacheCurrentTemp = currentWeather.temperature; - } else { - currentWeather.temperature = this.cacheCurrentTemp; - } - - if (ECdoc.querySelector("siteData currentConditions wind speed").textContent === "calm") { - currentWeather.windSpeed = "0"; - } else { - currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent); - } - - currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent; - - currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent; - - /* - * Ensure showPrecipitationAmount is forced to false. EC does not really provide POP for current day - * and this feature for the weather module (current only) is sort of broken in that it wants - * to say POP but will display precip as an accumulated amount vs. a percentage. - */ - this.config.showPrecipitationAmount = false; - - /* - * If the module config wants to showFeelsLike... default to the current temperature. - * Check for EC wind chill and humidex values and overwrite the feelsLikeTemp value. - * This assumes that the EC current conditions will never contain both a wind chill - * and humidex temperature. - */ - if (this.config.showFeelsLike) { - currentWeather.feelsLikeTemp = currentWeather.temperature; - - if (ECdoc.querySelector("siteData currentConditions windChill")) { - currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions windChill").textContent; - } - - if (ECdoc.querySelector("siteData currentConditions humidex")) { - currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions humidex").textContent; - } - } - - // Need to map EC weather icon to MM weatherType values - currentWeather.weatherType = this.convertWeatherType(ECdoc.querySelector("siteData currentConditions iconCode").textContent); - - // Capture the sunrise and sunset values from EC data - const sunList = ECdoc.querySelectorAll("siteData riseSet dateTime"); - - currentWeather.sunrise = moment(sunList[1].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); - currentWeather.sunset = moment(sunList[3].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); - - return currentWeather; - }, - - /* - * Generate an array of WeatherObjects based on EC weather forecast - */ - generateWeatherObjectsFromForecast (ECdoc) { - // Declare an array to hold each day's forecast object - const days = []; - - const weather = new WeatherObject(); - - const foreBaseDates = ECdoc.querySelectorAll("siteData forecastGroup dateTime"); - const baseDate = foreBaseDates[1].querySelector("timeStamp").textContent; - - weather.date = moment(baseDate, "YYYYMMDDhhmmss"); - - const foreGroup = ECdoc.querySelectorAll("siteData forecastGroup forecast"); - - weather.precipitationAmount = null; - - /* - * The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing - * 2 elements. the first element for a day details the Today (daytime) forecast while the second - * element details the Tonight (nighttime) forecast. Element 0 is always for the current day. - * - * However... the forecast is somewhat 'rolling'. - * - * If the EC forecast is queried in the morning, then Element 0 will contain Current - * Today and Element 1 will contain Current Tonight. From there, the next 5 days of forecast will be - * contained in Elements 2/3, 4/5, 6/7, 8/9, and 10/11. This module will create a 6-day forecast using - * all of these Elements. - * - * But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled - * off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in - * Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Element 11 will contain a forecast for a 6th day, - * but only for the Today portion (not Tonight). This module will create a 6-day forecast using - * Elements 0 to 11, and will ignore the additional Today forecast in Element 11. - * - * We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight. - * This is required to understand how Min and Max temperature will be determined, and to understand - * where the next day's (aka Tomorrow's) forecast is located in the forecast array. - */ - let nextDay = 0; - let lastDay = 0; - const currentTemp = ECdoc.querySelector("siteData currentConditions temperature").textContent; - - // If the first Element is Current Today, look at Current Today and Current Tonight for the current day. - if (foreGroup[0].querySelector("period[textForecastName='Today']")) { - this.todaytempCacheMin = 0; - this.todaytempCacheMax = 0; - this.todayCached = true; - - this.setMinMaxTemps(weather, foreGroup, 0, true, currentTemp); - - this.setPrecipitation(weather, foreGroup, 0); - - /* - * Set the Element number that will reflect where the next day's forecast is located. Also set - * the Element number where the end of the forecast will be. This is important because of the - * rolling nature of the EC forecast. In the current scenario (Today and Tonight are present - * in elements 0 and 11, we know that we will have 6 full days of forecasts and we will use - * them. We will set lastDay such that we iterate through all 12 elements of the forecast. - */ - nextDay = 2; - lastDay = 12; - } - - // If the first Element is Current Tonight, look at Tonight only for the current day. - if (foreGroup[0].querySelector("period[textForecastName='Tonight']")) { - this.setMinMaxTemps(weather, foreGroup, 0, false, currentTemp); - - this.setPrecipitation(weather, foreGroup, 0); - - /* - * Set the Element number that will reflect where the next day's forecast is located. Also set - * the Element number where the end of the forecast will be. This is important because of the - * rolling nature of the EC forecast. In the current scenario (only Current Tonight is present - * in Element 0, we know that we will have 6 full days of forecasts PLUS a half-day and - * forecast in the final element. Because we will only use full day forecasts, we set the - * lastDay number to ensure we ignore that final half-day (in forecast Element 11). - */ - nextDay = 1; - lastDay = 11; - } - - /* - * Need to map EC weather icon to MM weatherType values. Always pick the first Element's icon to - * reflect either Today or Tonight depending on what the forecast is showing in Element 0. - */ - weather.weatherType = this.convertWeatherType(foreGroup[0].querySelector("abbreviatedForecast iconCode").textContent); - - // Push the weather object into the forecast array. - days.push(weather); - - /* - * Now do the rest of the forecast starting at nextDay. We will process each day using 2 EC - * forecast Elements. This will address the fact that the EC forecast always includes Today and - * Tonight for each day. This is why we iterate through the forecast by a a count of 2, with each - * iteration looking at the current Element and the next Element. - */ - let lastDate = moment(baseDate, "YYYYMMDDhhmmss"); - - for (let stepDay = nextDay; stepDay < lastDay; stepDay += 2) { - let weather = new WeatherObject(); - - // Add 1 to the date to reflect the current forecast day we are building - lastDate = lastDate.add(1, "day"); - weather.date = moment(lastDate); - - /* - * Capture the temperatures for the current Element and the next Element in order to set - * the Min and Max temperatures for the forecast - */ - this.setMinMaxTemps(weather, foreGroup, stepDay, true, currentTemp); - - weather.precipitationAmount = null; - - this.setPrecipitation(weather, foreGroup, stepDay); - - // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. - weather.weatherType = this.convertWeatherType(foreGroup[stepDay].querySelector("abbreviatedForecast iconCode").textContent); - - // Push the weather object into the forecast array. - days.push(weather); - } - - return days; - }, - - /* - * Generate an array of WeatherObjects based on EC hourly weather forecast - */ - generateWeatherObjectsFromHourly (ECdoc) { - // Declare an array to hold each hour's forecast object - const hours = []; - - // Get local timezone UTC offset so that each hourly time can be calculated properly - const baseHours = ECdoc.querySelectorAll("siteData hourlyForecastGroup dateTime"); - const hourOffset = baseHours[1].getAttribute("UTCOffset"); - - /* - * The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding - * the forecast for the next 'on the hour' time slot. This means the array is a rolling 24 hours. - */ - const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast"); - - for (let stepHour = 0; stepHour < 24; stepHour += 1) { - const weather = new WeatherObject(); - - // Determine local time by applying UTC offset to the forecast timestamp - const foreTime = moment(hourGroup[stepHour].getAttribute("dateTimeUTC"), "YYYYMMDDhhmmss"); - const currTime = foreTime.add(hourOffset, "hours"); - weather.date = moment(currTime); - - // Capture the temperature - weather.temperature = hourGroup[stepHour].querySelector("temperature").textContent; - - // Capture Likelihood of Precipitation (LOP) and unit-of-measure values - const precipLOP = hourGroup[stepHour].querySelector("lop").textContent * 1.0; - - if (precipLOP > 0) { - weather.precipitationProbability = precipLOP; - } - - // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. - weather.weatherType = this.convertWeatherType(hourGroup[stepHour].querySelector("iconCode").textContent); - - // Push the weather object into the forecast array. - hours.push(weather); - } - - return hours; - }, - - /* - * Determine Min and Max temp based on a supplied Forecast Element index and a boolean that denotes if - * the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only - */ - setMinMaxTemps (weather, foreGroup, today, fullDay, currentTemp) { - const todayTemp = foreGroup[today].querySelector("temperatures temperature").textContent; - - const todayClass = foreGroup[today].querySelector("temperatures temperature").getAttribute("class"); - - /* - * The following logic is largely aimed at accommodating the Current day's forecast whereby we - * can have either Current Today+Current Tonight or only Current Tonight. - * - * If fullDay is false, then we only have Tonight for the current day's forecast - meaning we have - * lost a min or max temp value for the day. Therefore, we will see if we were able to cache the the - * Today forecast for the current day. If we have, we will use them. If we do not have the cached values, - * it means that MM or the Computer has been restarted since the time EC rolled off Today from the - * forecast. In this scenario, we will simply default to the Current Conditions temperature and then - * check the Tonight temperature.x - */ - if (fullDay === false) { - if (this.todayCached === true) { - weather.minTemperature = this.todayTempCacheMin; - weather.maxTemperature = this.todayTempCacheMax; - } else { - weather.minTemperature = currentTemp; - weather.maxTemperature = weather.minTemperature; - } - } - - /* - * We will check to see if the current Element's temperature is Low or High and set weather values - * accordingly. We will also check the condition where fullDay is true *and* we are looking at forecast - * element 0. This is a special case where we will cache temperature values so that we have them later - * in the current day when the Current Today element rolls off and we have Current Tonight only. - */ - if (todayClass === "low") { - weather.minTemperature = todayTemp; - if (today === 0 && fullDay === true) { - this.todayTempCacheMin = weather.minTemperature; - } - } - - if (todayClass === "high") { - weather.maxTemperature = todayTemp; - if (today === 0 && fullDay === true) { - this.todayTempCacheMax = weather.maxTemperature; - } - } - - const nextTemp = foreGroup[today + 1].querySelector("temperatures temperature").textContent; - - const nextClass = foreGroup[today + 1].querySelector("temperatures temperature").getAttribute("class"); - - if (fullDay === true) { - if (nextClass === "low") { - weather.minTemperature = nextTemp; - } - - if (nextClass === "high") { - weather.maxTemperature = nextTemp; - } - } - }, - - /* - * Check for a Precipitation forecast. EC can provide a forecast in 2 ways: either an accumulation figure - * or a POP percentage. If there is a POP, then that is what the module will show. If there is an accumulation, - * then it will be displayed ONLY if no POP is present. - * - * POP Logic: By default, we want to show the POP for 'daytime' since we are presuming that is what - * people are more interested in seeing. While EC provides a separate POP for daytime and nighttime portions - * of each day, the weather module does not really allow for that view of a daily forecast. There we will - * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show - * the nighttime forecast after a certain point in the afternoon. As such, we will be showing the nighttime POP - * (if one exists) in that specific scenario. - * - * Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what - * people are interested in seeing. While EC provides a separate accumulation for daytime and nighttime portions - * of each day, the weather module does not really allow for that view of a daily forecast. There we will - * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show - * the nighttime forecast after a certain point in that specific scenario. - */ - setPrecipitation (weather, foreGroup, today) { - if (foreGroup[today].querySelector("precipitation accumulation")) { - weather.precipitationAmount = foreGroup[today].querySelector("precipitation accumulation amount").textContent * 1.0; - weather.precipitationUnits = foreGroup[today].querySelector("precipitation accumulation amount").getAttribute("units"); - } - - // Check Today element for POP - const precipPOP = foreGroup[today].querySelector("abbreviatedForecast pop").textContent * 1.0; - if (precipPOP > 0) { - weather.precipitationProbability = precipPOP; - } - }, - - /* - * Convert the icons to a more usable name. - */ - convertWeatherType (weatherType) { - const weatherTypes = { - "00": "day-sunny", - "01": "day-sunny", - "02": "day-sunny-overcast", - "03": "day-cloudy", - "04": "day-cloudy", - "05": "day-cloudy", - "06": "day-sprinkle", - "07": "day-showers", - "08": "day-snow", - "09": "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 weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - } -}); diff --git a/modules/default/weather/providers/openmeteo.js b/modules/default/weather/providers/openmeteo.js deleted file mode 100644 index c9aaaf567d..0000000000 --- a/modules/default/weather/providers/openmeteo.js +++ /dev/null @@ -1,557 +0,0 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for Open-Meteo, - * see https://open-meteo.com/ - */ - -// 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"; - -WeatherProvider.register("openmeteo", { - - /* - * Set the name of the provider. - * Not strictly required but helps for debugging. - */ - providerName: "Open-Meteo", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: OPEN_METEO_BASE, - lat: 0, - lon: 0, - pastDays: 0, - type: "current" - }, - - // https://open-meteo.com/en/docs - hourlyParams: [ - // Air temperature at 2 meters above ground - "temperature_2m", - // Relative humidity at 2 meters above ground - "relativehumidity_2m", - // Dew point temperature at 2 meters above ground - "dewpoint_2m", - // Apparent temperature is the perceived feels-like temperature combining wind chill factor, relative humidity and solar radiation - "apparent_temperature", - // Atmospheric air pressure reduced to mean sea level (msl) or pressure at surface. Typically pressure on mean sea level is used in meteorology. Surface pressure gets lower with increasing elevation. - "pressure_msl", - "surface_pressure", - // Total cloud cover as an area fraction - "cloudcover", - // Low level clouds and fog up to 3 km altitude - "cloudcover_low", - // Mid level clouds from 3 to 8 km altitude - "cloudcover_mid", - // High level clouds from 8 km altitude - "cloudcover_high", - // Wind speed at 10, 80, 120 or 180 meters above ground. Wind speed on 10 meters is the standard level. - "windspeed_10m", - "windspeed_80m", - "windspeed_120m", - "windspeed_180m", - // Wind direction at 10, 80, 120 or 180 meters above ground - "winddirection_10m", - "winddirection_80m", - "winddirection_120m", - "winddirection_180m", - // Gusts at 10 meters above ground as a maximum of the preceding hour - "windgusts_10m", - // Shortwave solar radiation as average of the preceding hour. This is equal to the total global horizontal irradiation - "shortwave_radiation", - // Direct solar radiation as average of the preceding hour on the horizontal plane and the normal plane (perpendicular to the sun) - "direct_radiation", - "direct_normal_irradiance", - // Diffuse solar radiation as average of the preceding hour - "diffuse_radiation", - // Vapor Pressure Deificit (VPD) in kilopascal (kPa). For high VPD (>1.6), water transpiration of plants increases. For low VPD (<0.4), transpiration decreases - "vapor_pressure_deficit", - // Evapotranspration from land surface and plants that weather models assumes for this location. Available soil water is considered. 1 mm evapotranspiration per hour equals 1 liter of water per spare meter. - "evapotranspiration", - // ET₀ Reference Evapotranspiration of a well watered grass field. Based on FAO-56 Penman-Monteith equations ET₀ is calculated from temperature, wind speed, humidity and solar radiation. Unlimited soil water is assumed. ET₀ is commonly used to estimate the required irrigation for plants. - "et0_fao_evapotranspiration", - // Total precipitation (rain, showers, snow) sum of the preceding hour - "precipitation", - // Precipitation Probability - "precipitation_probability", - // UV index - "uv_index", - // Snowfall amount of the preceding hour in centimeters. For the water equivalent in millimeter, divide by 7. E.g. 7 cm snow = 10 mm precipitation water equivalent - "snowfall", - // Rain from large scale weather systems of the preceding hour in millimeter - "rain", - // Showers from convective precipitation in millimeters from the preceding hour - "showers", - // Weather condition as a numeric code. Follow WMO weather interpretation codes. - "weathercode", - // Snow depth on the ground - "snow_depth", - // Altitude above sea level of the 0°C level - "freezinglevel_height", - // Temperature in the soil at 0, 6, 18 and 54 cm depths. 0 cm is the surface temperature on land or water surface temperature on water. - "soil_temperature_0cm", - "soil_temperature_6cm", - "soil_temperature_18cm", - "soil_temperature_54cm", - // Average soil water content as volumetric mixing ratio at 0-1, 1-3, 3-9, 9-27 and 27-81 cm depths. - "soil_moisture_0_1cm", - "soil_moisture_1_3cm", - "soil_moisture_3_9cm", - "soil_moisture_9_27cm", - "soil_moisture_27_81cm" - ], - - dailyParams: [ - // Maximum and minimum daily air temperature at 2 meters above ground - "temperature_2m_max", - "temperature_2m_min", - // Maximum and minimum daily apparent temperature - "apparent_temperature_min", - "apparent_temperature_max", - // Sum of daily precipitation (including rain, showers and snowfall) - "precipitation_sum", - // Sum of daily rain - "rain_sum", - // Sum of daily showers - "showers_sum", - // Sum of daily snowfall - "snowfall_sum", - // The number of hours with rain - "precipitation_hours", - // The most severe weather condition on a given day - "weathercode", - // Sun rise and set times - "sunrise", - "sunset", - // Maximum wind speed and gusts on a day - "windspeed_10m_max", - "windgusts_10m_max", - // Dominant wind direction - "winddirection_10m_dominant", - // The sum of solar radiation on a given day in Megajoules - "shortwave_radiation_sum", - //UV Index - "uv_index_max", - // Daily sum of ET₀ Reference Evapotranspiration of a well watered grass field - "et0_fao_evapotranspiration" - ], - - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => this.parseWeatherApiResponse(data)) - .then((parsedData) => { - if (!parsedData) { - // No usable data? - return; - } - - const currentWeather = this.generateWeatherDayFromCurrentWeather(parsedData); - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => this.parseWeatherApiResponse(data)) - .then((parsedData) => { - if (!parsedData) { - // No usable data? - return; - } - - const dailyForecast = this.generateWeatherObjectsFromForecast(parsedData); - this.setWeatherForecast(dailyForecast); - }) - .catch(function (request) { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherHourly () { - this.fetchData(this.getUrl()) - .then((data) => this.parseWeatherApiResponse(data)) - .then((parsedData) => { - if (!parsedData) { - // No usable data? - return; - } - - const hourlyForecast = this.generateWeatherObjectsFromHourly(parsedData); - this.setWeatherHourly(hourlyForecast); - }) - .catch(function (request) { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - /** - * Overrides method for setting config to check if endpoint is correct for hourly - * @param {object} config The configuration object - */ - setConfig (config) { - this.config = { - lang: config.lang ?? "en", - ...this.defaults, - ...config - }; - - // Set properly maxNumberOfDays and max Entries properties according to config and value ranges allowed in the documentation - const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0; - if (this.config.hasOwnProperty("maxNumberOfDays") && !isNaN(parseFloat(this.config.maxNumberOfDays))) { - const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0; - this.config.maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit)); - this.config.maxNumberOfDays = Math.ceil(this.config.maxEntries / Math.max(1, daysFactor)); - } - this.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit)); - - if (!this.config.type) { - Log.error("[weatherprovider.openmeteo] type not configured and could not resolve it"); - } - - this.fetchLocation(); - }, - - // Generate valid query params to perform the request - getQueryParameters () { - let 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, - // Fixed units as metric - temperature_unit: "celsius", - windspeed_unit: "ms", - precipitation_unit: "mm" - }; - - const startDate = moment().startOf("day"); - const endDate = moment(startDate) - .add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), "days") - .endOf("day"); - - params.start_date = startDate.format("YYYY-MM-DD"); - - switch (this.config.type) { - case "hourly": - case "daily": - case "forecast": - params.end_date = endDate.format("YYYY-MM-DD"); - break; - case "current": - params.current_weather = true; - params.end_date = params.start_date; - break; - default: - // Failsafe - return ""; - } - - return Object.keys(params) - .filter((key) => (!!params[key])) - .map((key) => { - switch (key) { - case "hourly": - case "daily": - return `${encodeURIComponent(key)}=${params[key].join(",")}`; - default: - return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`; - } - }) - .join("&"); - }, - - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}/forecast?${this.getQueryParameters()}`; - }, - - // fix daylight-saving-time differences - checkDST (dt) { - const uxdt = moment.unix(dt); - const nowDST = moment().isDST(); - if (nowDST === moment(uxdt).isDST()) { - return uxdt; - } else { - return uxdt.add(nowDST ? +1 : -1, "hour"); - } - }, - - // Transpose hourly and daily data matrices - transposeDataMatrix (data) { - return data.time.map((_, index) => Object.keys(data).reduce((row, key) => { - return { - ...row, - // Parse time values as moment.js instances - [key]: ["time", "sunrise", "sunset"].includes(key) ? this.checkDST(data[key][index]) : data[key][index] - }; - }, {})); - }, - - // Sanitize and validate API response - 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 - }; - // backwards compatibility - const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type; - - if (!validByType[type]) return; - - switch (type) { - case "current": - if (!validByType.daily && !validByType.hourly) { - return; - } - break; - case "hourly": - case "daily": - break; - default: - return; - } - - 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 = moment.unix(data.current_weather.time); - } - - return data; - }, - - // Reverse geocoding from latitude and longitude provided - fetchLocation () { - this.fetchData(`${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang}`) - .then((data) => { - if (!data || !data.city) { - // No usable data? - return; - } - this.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`; - }) - .catch((request) => { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }); - }, - - // Implement WeatherDay generator. - generateWeatherDayFromCurrentWeather (weather) { - - /** - * Since some units come from API response "splitted" into daily, hourly and current_weather - * every time you request it, you have to ensure to get the data from the right place every time. - * For the current weather case, the response have the following structure (after transposing): - * ``` - * { - * current_weather: { ... }, - * hourly: [ - * 0: {... }, - * 1: {... }, - * ... - * ], - * daily: [ - * {... }, - * ] - * } - * ``` - * Some data should be returned from `hourly` array data when the index matches the current hour, - * some data from the first and only one object received in `daily` array and some from the - * `current_weather` object. - */ - const h = moment().hour(); - const currentWeather = new WeatherObject(); - - currentWeather.date = weather.current_weather.time; - currentWeather.windSpeed = weather.current_weather.windspeed; - currentWeather.windFromDirection = weather.current_weather.winddirection; - currentWeather.sunrise = weather.daily[0].sunrise; - currentWeather.sunset = weather.daily[0].sunset; - currentWeather.temperature = parseFloat(weather.current_weather.temperature); - currentWeather.minTemperature = parseFloat(weather.daily[0].temperature_2m_min); - currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max); - currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime()); - currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m); - currentWeather.feelsLikeTemp = parseFloat(weather.hourly[h].apparent_temperature); - currentWeather.rain = parseFloat(weather.hourly[h].rain); - currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10); - currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation); - currentWeather.precipitationProbability = parseFloat(weather.hourly[h].precipitation_probability); - currentWeather.uv_index = parseFloat(weather.hourly[h].uv_index); - - return currentWeather; - }, - - // Implement WeatherForecast generator. - generateWeatherObjectsFromForecast (weathers) { - const days = []; - - weathers.daily.forEach((weather) => { - const currentWeather = new WeatherObject(); - - currentWeather.date = weather.time; - currentWeather.windSpeed = weather.windspeed_10m_max; - currentWeather.windFromDirection = weather.winddirection_10m_dominant; - currentWeather.sunrise = weather.sunrise; - currentWeather.sunset = weather.sunset; - currentWeather.temperature = parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2); - currentWeather.minTemperature = parseFloat(weather.temperature_2m_min); - currentWeather.maxTemperature = parseFloat(weather.temperature_2m_max); - currentWeather.weatherType = this.convertWeatherType(weather.weathercode, true); - currentWeather.rain = parseFloat(weather.rain_sum); - currentWeather.snow = parseFloat(weather.snowfall_sum * 10); - currentWeather.precipitationAmount = parseFloat(weather.precipitation_sum); - currentWeather.precipitationProbability = parseFloat(weather.precipitation_hours * 100 / 24); - currentWeather.uv_index = parseFloat(weather.uv_index_max); - - days.push(currentWeather); - }); - - return days; - }, - - // Implement WeatherHourly generator. - generateWeatherObjectsFromHourly (weathers) { - const hours = []; - const now = moment(); - - weathers.hourly.forEach((weather, i) => { - if ((hours.length === 0 && weather.time <= now) || hours.length >= this.config.maxEntries) { - return; - } - - const currentWeather = new WeatherObject(); - const h = Math.ceil((i + 1) / 24) - 1; - - currentWeather.date = weather.time; - currentWeather.windSpeed = weather.windspeed_10m; - currentWeather.windFromDirection = weather.winddirection_10m; - currentWeather.sunrise = weathers.daily[h].sunrise; - currentWeather.sunset = weathers.daily[h].sunset; - currentWeather.temperature = parseFloat(weather.temperature_2m); - currentWeather.minTemperature = parseFloat(weathers.daily[h].temperature_2m_min); - currentWeather.maxTemperature = parseFloat(weathers.daily[h].temperature_2m_max); - currentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime()); - currentWeather.humidity = parseFloat(weather.relativehumidity_2m); - currentWeather.rain = parseFloat(weather.rain); - currentWeather.snow = parseFloat(weather.snowfall * 10); - currentWeather.precipitationAmount = parseFloat(weather.precipitation); - currentWeather.precipitationProbability = parseFloat(weather.precipitation_probability); - currentWeather.uv_index = parseFloat(weather.uv_index); - - hours.push(currentWeather); - }); - - return hours; - }, - - // Map icons from Dark Sky to our icons. - 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 (!Object.keys(weatherConditions).includes(`${weathercode}`)) return null; - - switch (weatherConditions[`${weathercode}`]) { - case "clear": - return isDayTime ? "day-sunny" : "night-clear"; - case "mainly-clear": - case "partly-cloudy": - return isDayTime ? "day-cloudy" : "night-alt-cloudy"; - case "overcast": - return isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy"; - case "fog": - case "depositing-rime-fog": - return isDayTime ? "day-fog" : "night-fog"; - case "drizzle-light-intensity": - case "rain-slight-intensity": - case "rain-showers-slight": - return isDayTime ? "day-sprinkle" : "night-sprinkle"; - case "drizzle-moderate-intensity": - case "rain-moderate-intensity": - case "rain-showers-moderate": - return isDayTime ? "day-showers" : "night-showers"; - case "drizzle-dense-intensity": - case "rain-heavy-intensity": - case "rain-showers-violent": - return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; - case "freezing-rain-light-intensity": - return isDayTime ? "day-rain-mix" : "night-rain-mix"; - case "freezing-drizzle-light-intensity": - case "freezing-drizzle-dense-intensity": - return "snowflake-cold"; - case "snow-grains": - return isDayTime ? "day-sleet" : "night-sleet"; - case "snow-fall-slight-intensity": - case "snow-fall-moderate-intensity": - return isDayTime ? "day-snow-wind" : "night-snow-wind"; - case "snow-fall-heavy-intensity": - case "freezing-rain-heavy-intensity": - return isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm"; - case "snow-showers-slight": - case "snow-showers-heavy": - return isDayTime ? "day-rain-mix" : "night-rain-mix"; - case "thunderstorm": - return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; - case "thunderstorm-slight-hail": - return isDayTime ? "day-sleet" : "night-sleet"; - case "thunderstorm-heavy-hail": - return isDayTime ? "day-sleet-storm" : "night-sleet-storm"; - default: - return "na"; - } - }, - - // Define required scripts. - getScripts () { - return ["moment.js"]; - } -}); diff --git a/modules/default/weather/providers/openweathermap.js b/modules/default/weather/providers/openweathermap.js deleted file mode 100644 index 5ad0fa1460..0000000000 --- a/modules/default/weather/providers/openweathermap.js +++ /dev/null @@ -1,441 +0,0 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for Openweathermap, - * see https://openweathermap.org/ - */ -WeatherProvider.register("openweathermap", { - - /* - * Set the name of the provider. - * This isn't strictly necessary, since it will fallback to the provider identifier - * But for debugging (and future alerts) it would be nice to have the real name. - */ - providerName: "OpenWeatherMap", - - // Set the default config properties that is specific to this provider - defaults: { - apiVersion: "3.0", - apiBase: "https://api.openweathermap.org/data/", - // weatherEndpoint is "/onecall" since API 3.0 - // "/onecall", "/forecast" or "/weather" only for pro customers - weatherEndpoint: "/onecall", - locationID: false, - location: false, - // the /onecall endpoint needs lat / lon values, it doesn't support the locationId - lat: 0, - lon: 0, - apiKey: "" - }, - - // Overwrite the fetchCurrentWeather method. - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => { - let currentWeather; - if (this.config.weatherEndpoint === "/onecall") { - currentWeather = this.generateWeatherObjectsFromOnecall(data).current; - this.setFetchedLocation(`${data.timezone}`); - } else { - currentWeather = this.generateWeatherObjectFromCurrentWeather(data); - } - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherForecast method. - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => { - let forecast; - let location; - if (this.config.weatherEndpoint === "/onecall") { - forecast = this.generateWeatherObjectsFromOnecall(data).days; - location = `${data.timezone}`; - } else { - forecast = this.generateWeatherObjectsFromForecast(data.list); - location = `${data.city.name}, ${data.city.country}`; - } - this.setWeatherForecast(forecast); - this.setFetchedLocation(location); - }) - .catch(function (request) { - Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherHourly method. - fetchWeatherHourly () { - this.fetchData(this.getUrl()) - .then((data) => { - if (!data) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - return; - } - - this.setFetchedLocation(`(${data.lat},${data.lon})`); - - const weatherData = this.generateWeatherObjectsFromOnecall(data); - this.setWeatherHourly(weatherData.hours); - }) - .catch(function (request) { - Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - /** OpenWeatherMap Specific Methods - These are not part of the default provider methods */ - /* - * Gets the complete url for the request - */ - getUrl () { - return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.getParams(); - }, - - /* - * Generate a WeatherObject based on currentWeatherInformation - */ - generateWeatherObjectFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - currentWeather.date = moment.unix(currentWeatherData.dt); - currentWeather.humidity = currentWeatherData.main.humidity; - currentWeather.temperature = currentWeatherData.main.temp; - currentWeather.feelsLikeTemp = currentWeatherData.main.feels_like; - currentWeather.windSpeed = currentWeatherData.wind.speed; - currentWeather.windFromDirection = currentWeatherData.wind.deg; - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.weather[0].icon); - currentWeather.sunrise = moment.unix(currentWeatherData.sys.sunrise); - currentWeather.sunset = moment.unix(currentWeatherData.sys.sunset); - - return currentWeather; - }, - - /* - * Generate WeatherObjects based on forecast information - */ - generateWeatherObjectsFromForecast (forecasts) { - if (this.config.weatherEndpoint === "/forecast") { - return this.generateForecastHourly(forecasts); - } else if (this.config.weatherEndpoint === "/forecast/daily") { - return this.generateForecastDaily(forecasts); - } - // if weatherEndpoint does not match forecast or forecast/daily, what should be returned? - return [new WeatherObject()]; - }, - - /* - * Generate WeatherObjects based on One Call forecast information - */ - generateWeatherObjectsFromOnecall (data) { - if (this.config.weatherEndpoint === "/onecall") { - return this.fetchOnecall(data); - } - // if weatherEndpoint does not match onecall, what should be returned? - return { current: new WeatherObject(), hours: [], days: [] }; - }, - - /* - * Generate forecast information for 3-hourly forecast (available for free - * subscription). - */ - generateForecastHourly (forecasts) { - // initial variable declaration - const days = []; - // variables for temperature range and rain - let minTemp = []; - let maxTemp = []; - let rain = 0; - let snow = 0; - // variable for date - let date = ""; - let weather = new WeatherObject(); - - for (const forecast of forecasts) { - if (date !== moment.unix(forecast.dt).format("YYYY-MM-DD")) { - // calculate minimum/maximum temperature, specify rain amount - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); - weather.rain = rain; - weather.snow = snow; - weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); - // push weather information to days array - days.push(weather); - // create new weather-object - weather = new WeatherObject(); - - minTemp = []; - maxTemp = []; - rain = 0; - snow = 0; - - // set new date - date = moment.unix(forecast.dt).format("YYYY-MM-DD"); - - // specify date - weather.date = moment.unix(forecast.dt); - - // If the first value of today is later than 17:00, we have an icon at least! - weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); - } - - if (moment.unix(forecast.dt).format("H") >= 8 && moment.unix(forecast.dt).format("H") <= 17) { - weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); - } - - /* - * the same day as before - * add values from forecast to corresponding variables - */ - minTemp.push(forecast.main.temp_min); - maxTemp.push(forecast.main.temp_max); - - if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain["3h"])) { - rain += forecast.rain["3h"]; - } - - if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow["3h"])) { - snow += forecast.snow["3h"]; - } - } - - /* - * last day - * calculate minimum/maximum temperature, specify rain amount - */ - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); - weather.rain = rain; - weather.snow = snow; - weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); - // push weather information to days array - days.push(weather); - return days.slice(1); - }, - - /* - * Generate forecast information for daily forecast (available for paid - * subscription or old apiKey). - */ - generateForecastDaily (forecasts) { - // initial variable declaration - const days = []; - - for (const forecast of forecasts) { - const weather = new WeatherObject(); - - weather.date = moment.unix(forecast.dt); - weather.minTemperature = forecast.temp.min; - weather.maxTemperature = forecast.temp.max; - weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); - weather.rain = 0; - weather.snow = 0; - - /* - * forecast.rain not available if amount is zero - * The API always returns in millimeters - */ - if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain)) { - weather.rain = forecast.rain; - } - - /* - * forecast.snow not available if amount is zero - * The API always returns in millimeters - */ - if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow)) { - weather.snow = forecast.snow; - } - - weather.precipitationAmount = weather.rain + weather.snow; - weather.precipitationProbability = forecast.pop ? forecast.pop * 100 : undefined; - - days.push(weather); - } - - return days; - }, - - /* - * Fetch One Call forecast information (available for free subscription). - * Factors in timezone offsets. - * Minutely forecasts are excluded for the moment, see getParams(). - */ - fetchOnecall (data) { - let precip = false; - - // get current weather, if requested - const current = new WeatherObject(); - if (data.hasOwnProperty("current")) { - current.date = moment.unix(data.current.dt).utcOffset(data.timezone_offset / 60); - current.windSpeed = data.current.wind_speed; - current.windFromDirection = data.current.wind_deg; - current.sunrise = moment.unix(data.current.sunrise).utcOffset(data.timezone_offset / 60); - current.sunset = moment.unix(data.current.sunset).utcOffset(data.timezone_offset / 60); - current.temperature = data.current.temp; - current.weatherType = this.convertWeatherType(data.current.weather[0].icon); - current.humidity = data.current.humidity; - current.uv_index = data.current.uvi; - 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; - } - - let weather = new WeatherObject(); - - // get hourly weather, if requested - const hours = []; - if (data.hasOwnProperty("hourly")) { - for (const hour of data.hourly) { - weather.date = moment.unix(hour.dt).utcOffset(data.timezone_offset / 60); - 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 = this.convertWeatherType(hour.weather[0].icon); - weather.precipitationProbability = hour.pop ? hour.pop * 100 : undefined; - weather.uv_index = 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); - weather = new WeatherObject(); - } - } - - // get daily weather, if requested - const days = []; - if (data.hasOwnProperty("daily")) { - for (const day of data.daily) { - weather.date = moment.unix(day.dt).utcOffset(data.timezone_offset / 60); - weather.sunrise = moment.unix(day.sunrise).utcOffset(data.timezone_offset / 60); - weather.sunset = moment.unix(day.sunset).utcOffset(data.timezone_offset / 60); - 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 = this.convertWeatherType(day.weather[0].icon); - weather.precipitationProbability = day.pop ? day.pop * 100 : undefined; - weather.uv_index = 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); - weather = new WeatherObject(); - } - } - - return { current: current, hours: hours, days: days }; - }, - - /* - * Convert the OpenWeatherMap icons to a more usable name. - */ - 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; - }, - - /* - * getParams(compliments) - * Generates an url with api parameters based on the config. - * - * return String - URL params. - */ - 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}`; - } else if (this.firstEvent && this.firstEvent.geo) { - params += `lat=${this.firstEvent.geo.lat}&lon=${this.firstEvent.geo.lon}`; - } else if (this.firstEvent && this.firstEvent.location) { - params += `q=${this.firstEvent.location}`; - } else { - // TODO hide doesn't exist! - this.hide(this.config.animationSpeed, { lockString: this.identifier }); - return; - } - - params += "&units=metric"; // WeatherProviders should use metric internally and use the units only for when displaying data - params += `&lang=${this.config.lang}`; - params += `&APPID=${this.config.apiKey}`; - - return params; - } -}); diff --git a/modules/default/weather/providers/overrideWrapper.js b/modules/default/weather/providers/overrideWrapper.js deleted file mode 100644 index 61afa10176..0000000000 --- a/modules/default/weather/providers/overrideWrapper.js +++ /dev/null @@ -1,112 +0,0 @@ -/* global Class, WeatherObject */ - -/* - * Wrapper class to enable overrides of currentOverrideWeatherObject. - * - * Sits between the weather.js module and the provider implementations to allow us to - * combine the incoming data from the CURRENT_WEATHER_OVERRIDE notification with the - * existing data received from the current api provider. If no notifications have - * been received then the api provider's data is used. - * - * The intent is to allow partial WeatherObjects from local sensors to augment or - * replace the WeatherObjects from the api providers. - * - * This class shares the signature of WeatherProvider, and passes any methods not - * concerning the current weather directly to the api provider implementation that - * is currently in use. - */ -const OverrideWrapper = Class.extend({ - baseProvider: null, - providerName: "localWrapper", - notificationWeatherObject: null, - currentOverrideWeatherObject: null, - - init (baseProvider) { - this.baseProvider = baseProvider; - - // Binding the scope of current weather functions so any fetchData calls with - // setCurrentWeather nested in them call this classes implementation instead - // of the provider's default - this.baseProvider.setCurrentWeather = this.setCurrentWeather.bind(this); - this.baseProvider.currentWeather = this.currentWeather.bind(this); - }, - - /* Unchanged Api Provider Methods */ - - setConfig (config) { - this.baseProvider.setConfig(config); - }, - start () { - this.baseProvider.start(); - }, - fetchCurrentWeather () { - this.baseProvider.fetchCurrentWeather(); - }, - fetchWeatherForecast () { - this.baseProvider.fetchWeatherForecast(); - }, - fetchWeatherHourly () { - this.baseProvider.fetchWeatherHourly(); - }, - weatherForecast () { - this.baseProvider.weatherForecast(); - }, - weatherHourly () { - this.baseProvider.weatherHourly(); - }, - fetchedLocation () { - this.baseProvider.fetchedLocation(); - }, - setWeatherForecast (weatherForecastArray) { - this.baseProvider.setWeatherForecast(weatherForecastArray); - }, - setWeatherHourly (weatherHourlyArray) { - this.baseProvider.setWeatherHourly(weatherHourlyArray); - }, - setFetchedLocation (name) { - this.baseProvider.setFetchedLocation(name); - }, - updateAvailable () { - this.baseProvider.updateAvailable(); - }, - async fetchData (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) { - this.baseProvider.fetchData(url, type, requestHeaders, expectedResponseHeaders); - }, - - /* Override Methods */ - - /** - * Override to return this scope's - * @returns {WeatherObject} The current weather object. May or may not contain overridden data. - */ - currentWeather () { - return this.currentOverrideWeatherObject; - }, - - /** - * Override to combine the overrideWeatherObject provided in the - * notificationReceived method with the currentOverrideWeatherObject provided by the - * api provider fetchData implementation. - * @param {WeatherObject} currentWeatherObject - the api provider weather object - */ - setCurrentWeather (currentWeatherObject) { - this.currentOverrideWeatherObject = Object.assign(currentWeatherObject, this.notificationWeatherObject); - }, - - /** - * Updates the overrideWeatherObject, calls setCurrentWeather to combine it with - * the existing current weather object provided by the base provider, and signals - * that an update is ready. - * @param {WeatherObject} payload - the weather object received from the CURRENT_WEATHER_OVERRIDE - * notification. Represents information to augment the - * existing currentOverrideWeatherObject with. - */ - notificationReceived (payload) { - this.notificationWeatherObject = payload; - - // setCurrentWeather combines the newly received notification weather with - // the existing weather object we return for current weather - this.setCurrentWeather(this.currentOverrideWeatherObject); - this.updateAvailable(); - } -}); diff --git a/modules/default/weather/providers/pirateweather.js b/modules/default/weather/providers/pirateweather.js deleted file mode 100644 index 73760578f2..0000000000 --- a/modules/default/weather/providers/pirateweather.js +++ /dev/null @@ -1,128 +0,0 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for Pirate Weather, it is a replacement for Dark Sky (same api), - * see http://pirateweather.net/en/latest/ - */ -WeatherProvider.register("pirateweather", { - - /* - * Set the name of the provider. - * Not strictly required, but helps for debugging. - */ - providerName: "pirateweather", - - // Set the default config properties that is specific to this provider - defaults: { - useCorsProxy: true, - apiBase: "https://api.pirateweather.net", - weatherEndpoint: "/forecast", - apiKey: "", - lat: 0, - lon: 0 - }, - - async fetchCurrentWeather () { - try { - const data = await this.fetchData(this.getUrl()); - if (!data || !data.currently || typeof data.currently.temperature === "undefined") { - throw new Error("No usable data received from Pirate Weather API."); - } - - const currentWeather = this.generateWeatherDayFromCurrentWeather(data); - this.setCurrentWeather(currentWeather); - } catch (error) { - Log.error("Could not load data ... ", error); - } finally { - this.updateAvailable(); - } - }, - - async fetchWeatherForecast () { - try { - const data = await this.fetchData(this.getUrl()); - if (!data || !data.daily || !data.daily.data.length) { - throw new Error("No usable data received from Pirate Weather API."); - } - - const forecast = this.generateWeatherObjectsFromForecast(data.daily.data); - this.setWeatherForecast(forecast); - } catch (error) { - Log.error("Could not load data ... ", error); - } finally { - this.updateAvailable(); - } - }, - - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${this.config.lang}`; - }, - - // Implement WeatherDay generator. - generateWeatherDayFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - currentWeather.date = moment(); - currentWeather.humidity = parseFloat(currentWeatherData.currently.humidity); - currentWeather.temperature = parseFloat(currentWeatherData.currently.temperature); - currentWeather.windSpeed = parseFloat(currentWeatherData.currently.windSpeed); - currentWeather.windFromDirection = currentWeatherData.currently.windBearing; - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.currently.icon); - currentWeather.sunrise = moment.unix(currentWeatherData.daily.data[0].sunriseTime); - currentWeather.sunset = moment.unix(currentWeatherData.daily.data[0].sunsetTime); - - return currentWeather; - }, - - generateWeatherObjectsFromForecast (forecasts) { - const days = []; - - for (const forecast of forecasts) { - const weather = new WeatherObject(); - - weather.date = moment.unix(forecast.time); - weather.minTemperature = forecast.temperatureMin; - weather.maxTemperature = forecast.temperatureMax; - weather.weatherType = this.convertWeatherType(forecast.icon); - weather.snow = 0; - weather.rain = 0; - - let precip = 0; - if (forecast.hasOwnProperty("precipAccumulation")) { - precip = forecast.precipAccumulation * 10; - } - - weather.precipitationAmount = precip; - if (forecast.hasOwnProperty("precipType")) { - if (forecast.precipType === "snow") { - weather.snow = precip; - } else { - weather.rain = precip; - } - } - - days.push(weather); - } - - return days; - }, - - // Map icons from Pirate Weather to our icons. - 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.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - } -}); diff --git a/modules/default/weather/providers/smhi.js b/modules/default/weather/providers/smhi.js deleted file mode 100644 index bcb873a9af..0000000000 --- a/modules/default/weather/providers/smhi.js +++ /dev/null @@ -1,331 +0,0 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for SMHI (Sweden only). - * Metric system is the only supported unit, - * see https://www.smhi.se/ - */ -WeatherProvider.register("smhi", { - providerName: "SMHI", - - // Set the default config properties that is specific to this provider - defaults: { - lat: 0, // Cant have more than 6 digits - lon: 0, // Cant have more than 6 digits - precipitationValue: "pmedian", - location: false - }, - - /** - * Implements method in interface for fetching current weather. - */ - fetchCurrentWeather () { - this.fetchData(this.getURL()) - .then((data) => { - const closest = this.getClosestToCurrentTime(data.timeSeries); - const coordinates = this.resolveCoordinates(data); - const weatherObject = this.convertWeatherDataToObject(closest, coordinates); - this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); - this.setCurrentWeather(weatherObject); - }) - .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) - .finally(() => this.updateAvailable()); - }, - - /** - * Implements method in interface for fetching a multi-day forecast. - */ - fetchWeatherForecast () { - this.fetchData(this.getURL()) - .then((data) => { - const coordinates = this.resolveCoordinates(data); - const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates); - this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); - this.setWeatherForecast(weatherObjects); - }) - .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) - .finally(() => this.updateAvailable()); - }, - - /** - * Implements method in interface for fetching hourly forecasts. - */ - fetchWeatherHourly () { - this.fetchData(this.getURL()) - .then((data) => { - const coordinates = this.resolveCoordinates(data); - const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates, "hour"); - this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); - this.setWeatherHourly(weatherObjects); - }) - .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) - .finally(() => this.updateAvailable()); - }, - - /** - * Overrides method for setting config with checks for the precipitationValue being unset or invalid - * @param {object} config The configuration object - */ - setConfig (config) { - this.config = config; - if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) { - Log.log(`[weatherprovider.smhi] invalid or not set: ${config.precipitationValue}`); - config.precipitationValue = this.defaults.precipitationValue; - } - }, - - /** - * Of all the times returned find out which one is closest to the current time, should be the first if the data isn't old. - * @param {object[]} times Array of time objects - * @returns {object} The weatherdata closest to the current time - */ - getClosestToCurrentTime (times) { - let now = moment(); - let minDiff = undefined; - for (const time of times) { - let diff = Math.abs(moment(time.validTime).diff(now)); - if (!minDiff || diff < Math.abs(moment(minDiff.validTime).diff(now))) { - minDiff = time; - } - } - return minDiff; - }, - - /** - * Get the forecast url for the configured coordinates - * @returns {string} the url for the specified coordinates - */ - getURL () { - const formatter = new Intl.NumberFormat("en-US", { - minimumFractionDigits: 6, - maximumFractionDigits: 6 - }); - const lon = formatter.format(this.config.lon); - const lat = formatter.format(this.config.lat); - return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`; - }, - - /** - * Calculates the apparent temperature based on known atmospheric data. - * @param {object} weatherData Weatherdata to use for the calculation - * @returns {number} The apparent temperature - */ - 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.E * ((17.27 * Ta) / (237.7 + Ta)); - - return Ta + 0.33 * p - 0.7 * ws - 4; - }, - - /** - * Converts the returned data into a WeatherObject with required properties set for both current weather and forecast. - * The returned units is always in metric system. - * Requires coordinates to determine if its daytime or nighttime to know which icon to use and also to set sunrise and sunset. - * @param {object} weatherData Weatherdata to convert - * @param {object} coordinates Coordinates of the locations of the weather - * @returns {WeatherObject} The converted weatherdata at the specified location - */ - convertWeatherDataToObject (weatherData, coordinates) { - let currentWeather = new WeatherObject(); - - currentWeather.date = moment(weatherData.validTime); - currentWeather.updateSunTime(coordinates.lat, coordinates.lon); - currentWeather.humidity = this.paramValue(weatherData, "r"); - currentWeather.temperature = this.paramValue(weatherData, "t"); - currentWeather.windSpeed = this.paramValue(weatherData, "ws"); - currentWeather.windFromDirection = this.paramValue(weatherData, "wd"); - currentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, "Wsymb2"), currentWeather.isDayTime()); - currentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData); - - /* - * Determine the precipitation amount and category and update the - * weatherObject with it, the value type to use can be configured or uses - * median as default. - */ - let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue); - switch (this.paramValue(weatherData, "pcat")) { - // 0 = No precipitation - case 1: // Snow - currentWeather.snow += precipitationValue; - currentWeather.precipitationAmount += precipitationValue; - break; - case 2: // Snow and rain, treat it as 50/50 snow and rain - currentWeather.snow += precipitationValue / 2; - currentWeather.rain += precipitationValue / 2; - currentWeather.precipitationAmount += precipitationValue; - break; - case 3: // Rain - case 4: // Drizzle - case 5: // Freezing rain - case 6: // Freezing drizzle - currentWeather.rain += precipitationValue; - currentWeather.precipitationAmount += precipitationValue; - break; - } - - return currentWeather; - }, - - /** - * Takes all the data points and converts it to one WeatherObject per day. - * @param {object[]} allWeatherData Array of weatherdata - * @param {object} coordinates Coordinates of the locations of the weather - * @param {string} groupBy The interval to use for grouping the data (day, hour) - * @returns {WeatherObject[]} Array of weather objects - */ - convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") { - let currentWeather; - let result = []; - - let allWeatherObjects = this.fillInGaps(allWeatherData).map((weatherData) => this.convertWeatherDataToObject(weatherData, coordinates)); - let dayWeatherTypes = []; - - for (const weatherObject of allWeatherObjects) { - //If its the first object or if a day/hour change we need to reset the summary object - if (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) { - currentWeather = new WeatherObject(); - dayWeatherTypes = []; - currentWeather.temperature = weatherObject.temperature; - currentWeather.date = weatherObject.date; - currentWeather.minTemperature = Infinity; - currentWeather.maxTemperature = -Infinity; - currentWeather.snow = 0; - currentWeather.rain = 0; - currentWeather.precipitationAmount = 0; - result.push(currentWeather); - } - - //Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast - if (weatherObject.isDayTime()) { - dayWeatherTypes.push(weatherObject.weatherType); - } - if (dayWeatherTypes.length > 0) { - currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)]; - } else { - currentWeather.weatherType = weatherObject.weatherType; - } - - //All other properties is either a sum, min or max of each hour - 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; - }, - - /** - * Resolve coordinates from the response data (probably preferably to use - * this if it's not matching the config values exactly) - * @param {object} data Response data from the weather service - * @returns {{lon, lat}} the lat/long coordinates of the data - */ - resolveCoordinates (data) { - return { lat: data.geometry.coordinates[0][1], lon: data.geometry.coordinates[0][0] }; - }, - - /** - * The distance between the data points is increasing in the data the more distant the prediction is. - * Find these gaps and fill them with the previous hours data to make the data returned a complete set. - * @param {object[]} data Response data from the weather service - * @returns {object[]} Given data with filled gaps - */ - fillInGaps (data) { - let result = []; - for (let i = 1; i < data.length; i++) { - let to = moment(data[i].validTime); - let from = moment(data[i - 1].validTime); - let hours = moment.duration(to.diff(from)).asHours(); - // For each hour add a datapoint but change the validTime - for (let j = 0; j < hours; j++) { - let current = Object.assign({}, data[i]); - current.validTime = from.clone().add(j, "hours").toISOString(); - result.push(current); - } - } - return result; - }, - - /** - * Helper method to get a property from the returned data set. - * @param {object} currentWeatherData Weatherdata to get from - * @param {string} name The name of the property - * @returns {string} The value of the property in the weatherdata - */ - paramValue (currentWeatherData, name) { - return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0]; - }, - - /** - * Map the icon value from SMHI to an icon that MagicMirror² understands. - * Uses different icons depending on if its daytime or nighttime. - * SMHI's description of what the numeric value means is the comment after the case. - * @param {number} input The SMHI icon value - * @param {boolean} isDayTime True if the icon should be for daytime, false for nighttime - * @returns {string} The icon name for the MagicMirror - */ - 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: - return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable cloudiness - case 4: - return isDayTime ? "day-cloudy" : "night-cloudy"; // Halfclear sky - case 5: - return "cloudy"; // Cloudy sky - case 6: - return "cloudy"; // Overcast - case 7: - return "fog"; // Fog - case 8: - return "showers"; // Light rain showers - case 9: - return "showers"; // Moderate rain showers - case 10: - return "showers"; // Heavy rain showers - case 11: - return "thunderstorm"; // Thunderstorm - case 12: - return "sleet"; // Light sleet showers - case 13: - return "sleet"; // Moderate sleet showers - case 14: - return "sleet"; // Heavy sleet showers - case 15: - return "snow"; // Light snow showers - case 16: - return "snow"; // Moderate snow showers - case 17: - return "snow"; // Heavy snow showers - case 18: - return "rain"; // Light rain - case 19: - return "rain"; // Moderate rain - case 20: - return "rain"; // Heavy rain - case 21: - return "thunderstorm"; // Thunder - case 22: - return "sleet"; // Light sleet - case 23: - return "sleet"; // Moderate sleet - case 24: - return "sleet"; // Heavy sleet - case 25: - return "snow"; // Light snowfall - case 26: - return "snow"; // Moderate snowfall - case 27: - return "snow"; // Heavy snowfall - default: - return ""; - } - } -}); diff --git a/modules/default/weather/providers/ukmetofficedatahub.js b/modules/default/weather/providers/ukmetofficedatahub.js deleted file mode 100644 index 4f77a368a4..0000000000 --- a/modules/default/weather/providers/ukmetofficedatahub.js +++ /dev/null @@ -1,276 +0,0 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for UK Met Office Data Hub (the replacement for their Data Point services). - * For more information on Data Hub, see https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub - * Data available: - * Hourly data for next 2 days ("hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-hourly.pdf - * 3-hourly data for the next 7 days ("3hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-3-hourly.pdf - * Daily data for the next 7 days ("daily") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-daily.pdf - * - * NOTES - * This provider requires longitude/latitude coordinates, rather than a location ID (as with the previous Met Office provider) - * Provide the following in your config.js file: - * weatherProvider: "ukmetofficedatahub", - * apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", - * apiKey: "[YOUR API KEY]", - * lat: [LATITUDE (DECIMAL)], - * lon: [LONGITUDE (DECIMAL)] - * - * At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when - * setting your update intervals. For reference, 360 requests per day is once every 4 minutes. - * - * Pay attention to the units of the supplied data from the Met Office - it is given in SI/metric units where applicable: - * - Temperatures are in degrees Celsius (°C) - * - Wind speeds are in metres per second (m/s) - * - Wind direction given in degrees (°) - * - Pressures are in Pascals (Pa) - * - Distances are in metres (m) - * - Probabilities and humidity are given as percentages (%) - * - Precipitation is measured in millimeters (mm) with rates per hour (mm/h) - * - * See the PDFs linked above for more information on the data their corresponding units. - */ - -WeatherProvider.register("ukmetofficedatahub", { - // Set the name of the provider. - providerName: "UK Met Office (DataHub)", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", - apiKey: "", - lat: 0, - lon: 0 - }, - - // Build URL with query strings according to DataHub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly) - getUrl (forecastType) { - let queryStrings = "?"; - queryStrings += `latitude=${this.config.lat}`; - queryStrings += `&longitude=${this.config.lon}`; - queryStrings += `&includeLocationName=${true}`; - - // Return URL, making sure there is a trailing "/" in the base URL. - return this.config.apiBase + (this.config.apiBase.endsWith("/") ? "" : "/") + forecastType + queryStrings; - }, - - /* - * Build the list of headers for the request - * For DataHub requests, the API key/secret are sent in the headers rather than as query strings. - * Headers defined according to Data Hub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly) - */ - getHeaders () { - return { - accept: "application/json", - apikey: this.config.apiKey - }; - }, - - // Fetch data using supplied URL and request headers - async fetchWeather (url, headers) { - const response = await fetch(url, { headers: headers }); - - // Return JSON data - return response.json(); - }, - - // Fetch hourly forecast data (to use for current weather) - fetchCurrentWeather () { - this.fetchWeather(this.getUrl("hourly"), this.getHeaders()) - .then((data) => { - // Check data is usable - if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad current/hourly data?", data); - return; - } - - // Set location name - this.setFetchedLocation(`${data.features[0].properties.location.name}`); - - // Generate current weather data - const currentWeather = this.generateWeatherObjectFromCurrentWeather(data); - this.setCurrentWeather(currentWeather); - }) - - // Catch any error(s) - .catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`)) - - // Let the module know there is data available - .finally(() => this.updateAvailable()); - }, - - // Create a WeatherObject using current weather data (data for the current hour) - generateWeatherObjectFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - // Extract the actual forecasts - let forecastDataHours = currentWeatherData.features[0].properties.timeSeries; - - // Define now - let nowUtc = moment.utc(); - - // Find hour that contains the current time - for (let hour in forecastDataHours) { - let forecastTime = moment.utc(forecastDataHours[hour].time); - if (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, "h")))) { - currentWeather.date = forecastTime; - currentWeather.windSpeed = forecastDataHours[hour].windSpeed10m; - currentWeather.windFromDirection = forecastDataHours[hour].windDirectionFrom10m; - currentWeather.temperature = forecastDataHours[hour].screenTemperature; - currentWeather.minTemperature = forecastDataHours[hour].minScreenAirTemp; - currentWeather.maxTemperature = forecastDataHours[hour].maxScreenAirTemp; - currentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode); - currentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity; - currentWeather.rain = forecastDataHours[hour].totalPrecipAmount; - currentWeather.snow = forecastDataHours[hour].totalSnowAmount; - currentWeather.precipitationProbability = forecastDataHours[hour].probOfPrecipitation; - currentWeather.feelsLikeTemp = forecastDataHours[hour].feelsLikeTemperature; - - /* - * Pass on full details, so they can be used in custom templates - * Note the units of the supplied data when using this (see top of file) - */ - currentWeather.rawData = forecastDataHours[hour]; - } - } - - /* - * Determine the sunrise/sunset times - (still) not supplied in UK Met Office data - * Passes {longitude, latitude} to SunCalc, could pass height to, but - * SunCalc.getTimes doesn't take that into account - */ - currentWeather.updateSunTime(this.config.lat, this.config.lon); - - return currentWeather; - }, - - // Fetch daily forecast data - fetchWeatherForecast () { - this.fetchWeather(this.getUrl("daily"), this.getHeaders()) - .then((data) => { - // Check data is usable - if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad forecast data?", data); - return; - } - - // Set location name - this.setFetchedLocation(`${data.features[0].properties.location.name}`); - - // Generate the forecast data - const forecast = this.generateWeatherObjectsFromForecast(data); - this.setWeatherForecast(forecast); - }) - - // Catch any error(s) - .catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`)) - - // Let the module know there is new data available - .finally(() => this.updateAvailable()); - }, - - // Create a WeatherObject for each day using daily forecast data - generateWeatherObjectsFromForecast (forecasts) { - const dailyForecasts = []; - - // Extract the actual forecasts - let forecastDataDays = forecasts.features[0].properties.timeSeries; - - // Define today - let today = moment.utc().startOf("date"); - - // Go through each day in the forecasts - for (let day in forecastDataDays) { - const forecastWeather = new WeatherObject(); - - // Get date of forecast - let forecastDate = moment.utc(forecastDataDays[day].time); - - // Check if forecast is for today or in the future (i.e., ignore yesterday's forecast) - if (forecastDate.isSameOrAfter(today)) { - forecastWeather.date = forecastDate; - forecastWeather.minTemperature = forecastDataDays[day].nightMinScreenTemperature; - forecastWeather.maxTemperature = forecastDataDays[day].dayMaxScreenTemperature; - - // Using daytime forecast values - forecastWeather.windSpeed = forecastDataDays[day].midday10MWindSpeed; - forecastWeather.windFromDirection = forecastDataDays[day].midday10MWindDirection; - forecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode); - forecastWeather.precipitationProbability = forecastDataDays[day].dayProbabilityOfPrecipitation; - forecastWeather.temperature = forecastDataDays[day].dayMaxScreenTemperature; - forecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity; - forecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain; - forecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow; - forecastWeather.feelsLikeTemp = forecastDataDays[day].dayMaxFeelsLikeTemp; - - /* - * Pass on full details, so they can be used in custom templates - * Note the units of the supplied data when using this (see top of file) - */ - forecastWeather.rawData = forecastDataDays[day]; - - dailyForecasts.push(forecastWeather); - } - } - - return dailyForecasts; - }, - - // Set the fetched location name. - setFetchedLocation (name) { - this.fetchedLocationName = name; - }, - - /* - * Match the Met Office "significant weather code" to a weathericons.css icon - * Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264 - * and: https://erikflowers.github.io/weather-icons/ - */ - 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.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - } -}); diff --git a/modules/default/weather/providers/weatherbit.js b/modules/default/weather/providers/weatherbit.js deleted file mode 100644 index 8423babb2b..0000000000 --- a/modules/default/weather/providers/weatherbit.js +++ /dev/null @@ -1,205 +0,0 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for Weatherbit, - * see https://www.weatherbit.io/ - */ -WeatherProvider.register("weatherbit", { - - /* - * Set the name of the provider. - * Not strictly required, but helps for debugging. - */ - providerName: "Weatherbit", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://api.weatherbit.io/v2.0", - apiKey: "", - lat: 0, - lon: 0 - }, - - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => { - if (!data || !data.data[0] || typeof data.data[0].temp === "undefined") { - // No usable data? - return; - } - - const currentWeather = this.generateWeatherDayFromCurrentWeather(data); - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherbit] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => { - if (!data || !data.data) { - // No usable data? - return; - } - - const forecast = this.generateWeatherObjectsFromForecast(data.data); - this.setWeatherForecast(forecast); - - this.fetchedLocationName = `${data.city_name}, ${data.state_code}`; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherbit] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - /** - * Overrides method for setting config to check if endpoint is correct for hourly - * @param {object} config The configuration object - */ - setConfig (config) { - this.config = config; - if (!this.config.weatherEndpoint) { - switch (this.config.type) { - case "hourly": - this.config.weatherEndpoint = "/forecast/hourly"; - break; - case "daily": - case "forecast": - this.config.weatherEndpoint = "/forecast/daily"; - break; - case "current": - this.config.weatherEndpoint = "/current"; - break; - default: - Log.error("[weatherprovider.weatherbit] weatherEndpoint not configured and could not resolve it based on type"); - } - } - }, - - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`; - }, - - // Implement WeatherDay generator. - generateWeatherDayFromCurrentWeather (currentWeatherData) { - //Calculate TZ Offset and invert to convert Sunrise/Sunset times to Local - const d = new Date(); - let tzOffset = d.getTimezoneOffset(); - tzOffset = tzOffset * -1; - - const currentWeather = new WeatherObject(); - - currentWeather.date = moment.unix(currentWeatherData.data[0].ts); - currentWeather.humidity = parseFloat(currentWeatherData.data[0].rh); - currentWeather.temperature = parseFloat(currentWeatherData.data[0].temp); - currentWeather.windSpeed = parseFloat(currentWeatherData.data[0].wind_spd); - currentWeather.windFromDirection = currentWeatherData.data[0].wind_dir; - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.data[0].weather.icon); - currentWeather.sunrise = moment(currentWeatherData.data[0].sunrise, "HH:mm").add(tzOffset, "m"); - currentWeather.sunset = moment(currentWeatherData.data[0].sunset, "HH:mm").add(tzOffset, "m"); - - this.fetchedLocationName = `${currentWeatherData.data[0].city_name}, ${currentWeatherData.data[0].state_code}`; - - return currentWeather; - }, - - generateWeatherObjectsFromForecast (forecasts) { - const days = []; - - for (const forecast of forecasts) { - const weather = new WeatherObject(); - - weather.date = moment(forecast.datetime, "YYYY-MM-DD"); - weather.minTemperature = forecast.min_temp; - weather.maxTemperature = forecast.max_temp; - weather.precipitationAmount = forecast.precip; - weather.precipitationProbability = forecast.pop; - weather.weatherType = this.convertWeatherType(forecast.weather.icon); - - days.push(weather); - } - - return days; - }, - - // Map icons from Dark Sky to our icons. - 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-shower", - d03n: "night-alt-shower", - r01d: "day-shower", - r01n: "night-alt-shower", - r02d: "day-rain", - r02n: "night-alt-rain", - r03d: "day-rain", - r03n: "night-alt-rain", - r04d: "day-sprinkle", - r04n: "night-alt-sprinkle", - r05d: "day-shower", - r05n: "night-alt-shower", - r06d: "day-shower", - r06n: "night-alt-shower", - 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.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - } -}); diff --git a/modules/default/weather/providers/weatherflow.js b/modules/default/weather/providers/weatherflow.js deleted file mode 100644 index 8e9fad6fd9..0000000000 --- a/modules/default/weather/providers/weatherflow.js +++ /dev/null @@ -1,150 +0,0 @@ -/* global WeatherProvider, WeatherObject, WeatherUtils */ - -/* - * This class is a provider for Weatherflow. - * Note that the Weatherflow API does not provide snowfall. - */ -WeatherProvider.register("weatherflow", { - - /* - * Set the name of the provider. - * Not strictly required, but helps for debugging - */ - providerName: "WeatherFlow", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://swd.weatherflow.com/swd/rest/", - token: "", - stationid: "" - }, - - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => { - const currentWeather = new WeatherObject(); - currentWeather.date = moment(); - - // Other available values: air_density, brightness, delta_t, dew_point, - // pressure_trend (i.e. rising/falling), sea_level_pressure, wind gust, and more. - - currentWeather.humidity = data.current_conditions.relative_humidity; - currentWeather.temperature = data.current_conditions.air_temperature; - currentWeather.feelsLikeTemp = data.current_conditions.feels_like; - currentWeather.windSpeed = WeatherUtils.convertWindToMs(data.current_conditions.wind_avg); - currentWeather.windFromDirection = data.current_conditions.wind_direction; - currentWeather.weatherType = this.convertWeatherType(data.current_conditions.icon); - currentWeather.uv_index = data.current_conditions.uv; - currentWeather.sunrise = moment.unix(data.forecast.daily[0].sunrise); - currentWeather.sunset = moment.unix(data.forecast.daily[0].sunset); - this.setCurrentWeather(currentWeather); - this.fetchedLocationName = data.location_name; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherflow] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => { - const days = []; - - for (const forecast of data.forecast.daily) { - const weather = new WeatherObject(); - - weather.date = moment.unix(forecast.day_start_local); - weather.minTemperature = forecast.air_temp_low; - weather.maxTemperature = forecast.air_temp_high; - weather.precipitationProbability = forecast.precip_probability; - weather.weatherType = this.convertWeatherType(forecast.icon); - - // Must manually build UV and Precipitation from hourly - weather.precipitationAmount = 0.0; // This will sum up rain and snow - weather.precipitationUnits = "mm"; - weather.uv_index = 0; - - for (const hour of data.forecast.hourly) { - const hour_time = moment.unix(hour.time); - if (hour_time.day() === weather.date.day()) { // Iterate though until day is reached - // Get data from today - weather.uv_index = Math.max(weather.uv_index, hour.uv); - weather.precipitationAmount += (hour.precip ?? 0); - } else if (hour_time.diff(weather.date) >= 86400) { - break; // No more data to be found - } - } - days.push(weather); - } - this.setWeatherForecast(days); - this.fetchedLocationName = data.location_name; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherflow] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherHourly () { - this.fetchData(this.getUrl()) - .then((data) => { - const hours = []; - for (const hour of data.forecast.hourly) { - const weather = new WeatherObject(); - - weather.date = moment.unix(hour.time); - weather.temperature = hour.air_temperature; - weather.feelsLikeTemp = hour.feels_like; - weather.humidity = hour.relative_humidity; - weather.windSpeed = hour.wind_avg; - weather.windFromDirection = hour.wind_direction; - weather.weatherType = this.convertWeatherType(hour.icon); - weather.precipitationProbability = hour.precip_probability; - weather.precipitationAmount = hour.precip; // NOTE: precipitation type is available - weather.precipitationUnits = "mm"; // Hardcoded via request, TODO: Add conversion - weather.uv_index = hour.uv; - - hours.push(weather); - if (hours.length >= 48) break; // 10 days of hours are available, best to trim down. - } - this.setWeatherHourly(hours); - this.fetchedLocationName = data.location_name; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherflow] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - 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.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - }, - - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}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}`; - } -}); diff --git a/modules/default/weather/providers/weathergov.js b/modules/default/weather/providers/weathergov.js deleted file mode 100644 index 7dae337b22..0000000000 --- a/modules/default/weather/providers/weathergov.js +++ /dev/null @@ -1,379 +0,0 @@ -/* global WeatherProvider, WeatherObject, WeatherUtils */ - -/* - * Provider: weather.gov - * https://weather-gov.github.io/api/general-faqs - * - * This class is a provider for weather.gov. - * Note that this is only for US locations (lat and lon) and does not require an API key - * Since it is free, there are some items missing - like sunrise, sunset - */ - -WeatherProvider.register("weathergov", { - - /* - * Set the name of the provider. - * This isn't strictly necessary, since it will fallback to the provider identifier - * But for debugging (and future alerts) it would be nice to have the real name. - */ - providerName: "Weather.gov", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://api.weather.gov/points/", - lat: 0, - lon: 0 - }, - - // Flag all needed URLs availability - configURLs: false, - - //This API has multiple urls involved - forecastURL: "tbd", - forecastHourlyURL: "tbd", - forecastGridDataURL: "tbd", - observationStationsURL: "tbd", - stationObsURL: "tbd", - - // Called to set the config, this config is the same as the weather module's config. - setConfig (config) { - this.config = config; - this.fetchWxGovURLs(this.config); - }, - - // This returns the name of the fetched location or an empty string. - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - // Overwrite the fetchCurrentWeather method. - fetchCurrentWeather () { - if (!this.configURLs) { - Log.info("[weatherprovider.weathergov] fetchCurrentWeather: fetch wx waiting on config URLs"); - return; - } - this.fetchData(this.stationObsURL) - .then((data) => { - if (!data || !data.properties) { - // Did not receive usable new data. - return; - } - const currentWeather = this.generateWeatherObjectFromCurrentWeather(data.properties); - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.weathergov] Could not load station obs data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherForecast method. - fetchWeatherForecast () { - if (!this.configURLs) { - Log.info("[weatherprovider.weathergov] fetchWeatherForecast: fetch wx waiting on config URLs"); - return; - } - this.fetchData(this.forecastURL) - .then((data) => { - if (!data || !data.properties || !data.properties.periods || !data.properties.periods.length) { - // Did not receive usable new data. - return; - } - const forecast = this.generateWeatherObjectsFromForecast(data.properties.periods); - this.setWeatherForecast(forecast); - }) - .catch(function (request) { - Log.error("[weatherprovider.weathergov] Could not load forecast hourly data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherHourly method. - fetchWeatherHourly () { - if (!this.configURLs) { - Log.info("[weatherprovider.weathergov] fetchWeatherHourly: fetch wx waiting on config URLs"); - return; - } - this.fetchData(this.forecastHourlyURL) - .then((data) => { - if (!data) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - return; - } - const hourly = this.generateWeatherObjectsFromHourly(data.properties.periods); - this.setWeatherHourly(hourly); - }) - .catch(function (request) { - Log.error("[weatherprovider.weathergov] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - /** Weather.gov Specific Methods - These are not part of the default provider methods */ - - /* - * Get specific URLs - */ - fetchWxGovURLs (config) { - this.fetchData(`${config.apiBase}/${config.lat},${config.lon}`) - .then((data) => { - if (!data || !data.properties) { - // points URL did not respond with usable data. - return; - } - this.fetchedLocationName = `${data.properties.relativeLocation.properties.city}, ${data.properties.relativeLocation.properties.state}`; - Log.log(`[weatherprovider.weathergov] Forecast location is ${this.fetchedLocationName}`); - this.forecastURL = `${data.properties.forecast}?units=si`; - this.forecastHourlyURL = `${data.properties.forecastHourly}?units=si`; - this.forecastGridDataURL = data.properties.forecastGridData; - this.observationStationsURL = data.properties.observationStations; - // with this URL, we chain another promise for the station obs URL - return this.fetchData(data.properties.observationStations); - }) - .then((obsData) => { - if (!obsData || !obsData.features) { - // obs station URL did not respond with usable data. - return; - } - this.stationObsURL = `${obsData.features[0].id}/observations/latest`; - }) - .catch((err) => { - Log.error("[weatherprovider.weathergov] fetchWxGovURLs error: ", err); - }) - .finally(() => { - // excellent, let's fetch some actual wx data - this.configURLs = true; - - // handle 'forecast' config, fall back to 'current' - if (config.type === "forecast") { - this.fetchWeatherForecast(); - } else if (config.type === "hourly") { - this.fetchWeatherHourly(); - } else { - this.fetchCurrentWeather(); - } - }); - }, - - /* - * Generate a WeatherObject based on hourlyWeatherInformation - * Weather.gov API uses specific units; API does not include choice of units - * ... object needs data in units based on config! - */ - generateWeatherObjectsFromHourly (forecasts) { - const days = []; - - // variable for date - let weather = new WeatherObject(); - for (const forecast of forecasts) { - weather.date = moment(forecast.startTime.slice(0, 19)); - if (forecast.windSpeed.search(" ") < 0) { - weather.windSpeed = forecast.windSpeed; - } else { - weather.windSpeed = forecast.windSpeed.slice(0, forecast.windSpeed.search(" ")); - } - weather.windSpeed = WeatherUtils.convertWindToMs(weather.windSpeed); - weather.windFromDirection = forecast.windDirection; - weather.temperature = forecast.temperature; - //assign probability of precipitation - if (forecast.probabilityOfPrecipitation.value === null) { - weather.precipitationProbability = 0; - } else { - weather.precipitationProbability = forecast.probabilityOfPrecipitation.value; - } - // use the forecast isDayTime attribute to help build the weatherType label - weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); - - days.push(weather); - - weather = new WeatherObject(); - } - - // push weather information to days array - days.push(weather); - return days; - }, - - /* - * Generate a WeatherObject based on currentWeatherInformation - * Weather.gov API uses specific units; API does not include choice of units - * ... object needs data in units based on config! - */ - generateWeatherObjectFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - currentWeather.date = moment(currentWeatherData.timestamp); - currentWeather.temperature = currentWeatherData.temperature.value; - currentWeather.windSpeed = WeatherUtils.convertWindToMs(currentWeatherData.windSpeed.value); - currentWeather.windFromDirection = currentWeatherData.windDirection.value; - currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value; - currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value; - currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value); - currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value; - if (currentWeatherData.heatIndex.value !== null) { - currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value; - } else if (currentWeatherData.windChill.value !== null) { - currentWeather.feelsLikeTemp = currentWeatherData.windChill.value; - } else { - currentWeather.feelsLikeTemp = currentWeatherData.temperature.value; - } - // determine the sunrise/sunset times - not supplied in weather.gov data - currentWeather.updateSunTime(this.config.lat, this.config.lon); - - // update weatherType - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.textDescription, currentWeather.isDayTime()); - - return currentWeather; - }, - - /* - * Generate WeatherObjects based on forecast information - */ - generateWeatherObjectsFromForecast (forecasts) { - return this.fetchForecastDaily(forecasts); - }, - - /* - * fetch forecast information for daily forecast. - */ - fetchForecastDaily (forecasts) { - // initial variable declaration - const days = []; - // variables for temperature range and rain - let minTemp = []; - let maxTemp = []; - // variable for date - let date = ""; - let weather = new WeatherObject(); - - for (const forecast of forecasts) { - if (date !== moment(forecast.startTime).format("YYYY-MM-DD")) { - // calculate minimum/maximum temperature, specify rain amount - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); - - // push weather information to days array - days.push(weather); - // create new weather-object - weather = new WeatherObject(); - - minTemp = []; - maxTemp = []; - //assign probability of precipitation - if (forecast.probabilityOfPrecipitation.value === null) { - weather.precipitationProbability = 0; - } else { - weather.precipitationProbability = forecast.probabilityOfPrecipitation.value; - } - - // set new date - date = moment(forecast.startTime).format("YYYY-MM-DD"); - - // specify date - weather.date = moment(forecast.startTime); - - // use the forecast isDayTime attribute to help build the weatherType label - weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); - } - - if (moment(forecast.startTime).format("H") >= 8 && moment(forecast.startTime).format("H") <= 17) { - weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); - } - - /* - * the same day as before - * add values from forecast to corresponding variables - */ - minTemp.push(forecast.temperature); - maxTemp.push(forecast.temperature); - } - - /* - * last day - * calculate minimum/maximum temperature - */ - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); - - // push weather information to days array - days.push(weather); - return days.slice(1); - }, - - /* - * Convert the icons to a more usable name. - */ - convertWeatherType (weatherType, isDaytime) { - - /* - * https://w1.weather.gov/xml/current_obs/weather.php - * There are way too many types to create, so lets just look for certain strings - */ - - if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) { - if (isDaytime) { - return "day-cloudy"; - } - - return "night-cloudy"; - } else if (weatherType.includes("Overcast")) { - if (isDaytime) { - return "cloudy"; - } - - return "night-cloudy"; - } else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) { - return "rain-mix"; - } else if (weatherType.includes("Snow")) { - if (isDaytime) { - return "snow"; - } - - return "night-snow"; - } else if (weatherType.includes("Thunderstorm")) { - if (isDaytime) { - return "thunderstorm"; - } - - return "night-thunderstorm"; - } else if (weatherType.includes("Showers")) { - if (isDaytime) { - return "showers"; - } - - return "night-showers"; - } else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) { - if (isDaytime) { - return "rain"; - } - - return "night-rain"; - } else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) { - if (isDaytime) { - return "cloudy-windy"; - } - - return "night-alt-cloudy-windy"; - } else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) { - if (isDaytime) { - return "day-sunny"; - } - - return "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; - } -}); diff --git a/modules/default/weather/providers/yr.js b/modules/default/weather/providers/yr.js deleted file mode 100644 index d5a6cb6d5c..0000000000 --- a/modules/default/weather/providers/yr.js +++ /dev/null @@ -1,623 +0,0 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for Yr.no, a norwegian weather service. - * Terms of service: https://developer.yr.no/doc/TermsOfService/ - */ -WeatherProvider.register("yr", { - providerName: "Yr", - - // Set the default config properties that is specific to this provider - defaults: { - useCorsProxy: true, - apiBase: "https://api.met.no/weatherapi", - forecastApiVersion: "2.0", - sunriseApiVersion: "3.0", - altitude: 0, - currentForecastHours: 1 //1, 6 or 12 - }, - - start () { - if (typeof Storage === "undefined") { - //local storage unavailable - Log.error("[weatherprovider.yr] The Yr weather provider requires local storage."); - throw new Error("Local storage not available"); - } - if (this.config.updateInterval < 600000) { - Log.warn("[weatherprovider.yr] The Yr weather provider requires a minimum update interval of 10 minutes (600 000 ms). The configuration has been adjusted to meet this requirement."); - this.delegate.config.updateInterval = 600000; - } - Log.info(`[weatherprovider.yr] ${this.providerName} started.`); - }, - - fetchCurrentWeather () { - this.getCurrentWeather() - .then((currentWeather) => { - this.setCurrentWeather(currentWeather); - this.updateAvailable(); - }) - .catch((error) => { - Log.error("[weatherprovider.yr] fetchCurrentWeather error:", error); - this.updateAvailable(); - }); - }, - - async getCurrentWeather () { - const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]); - if (!stellarData) { - Log.warn("[weatherprovider.yr] No stellar data available."); - } - if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { - Log.error("[weatherprovider.yr] No weather data available."); - return; - } - const currentTime = moment(); - let forecast = weatherData.properties.timeseries[0]; - let closestTimeInPast = currentTime.diff(moment(forecast.time)); - for (const forecastTime of weatherData.properties.timeseries) { - const comparison = currentTime.diff(moment(forecastTime.time)); - if (0 < comparison && comparison < closestTimeInPast) { - closestTimeInPast = comparison; - forecast = forecastTime; - } - } - const forecastXHours = this.getForecastForXHoursFrom(forecast.data); - forecast.weatherType = this.convertWeatherType(forecastXHours.summary.symbol_code, forecast.time); - forecast.precipitationAmount = forecastXHours.details?.precipitation_amount; - forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; - forecast.minTemperature = forecastXHours.details?.air_temperature_min; - forecast.maxTemperature = forecastXHours.details?.air_temperature_max; - return this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units); - }, - - getWeatherData () { - return new Promise((resolve, reject) => { - - /* - * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. - * This is to avoid multiple similar calls to the API. - */ - let shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); - if (shouldWait) { - const checkForGo = setInterval(function () { - shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); - }, 100); - setTimeout(function () { - clearInterval(checkForGo); - shouldWait = false; - }, 5000); //Assume other fetch finished but failed to remove lock - const attemptFetchWeather = setInterval(() => { - if (!shouldWait) { - clearInterval(checkForGo); - clearInterval(attemptFetchWeather); - this.getWeatherDataFromYrOrCache(resolve, reject); - } - }, 100); - } else { - this.getWeatherDataFromYrOrCache(resolve, reject); - } - }); - }, - - getWeatherDataFromYrOrCache (resolve, reject) { - localStorage.setItem("yrIsFetchingWeatherData", "true"); - - let weatherData = this.getWeatherDataFromCache(); - if (this.weatherDataIsValid(weatherData)) { - localStorage.removeItem("yrIsFetchingWeatherData"); - Log.debug("[weatherprovider.yr] Weather data found in cache."); - resolve(weatherData); - } else { - this.getWeatherDataFromYr(weatherData?.downloadedAt) - .then((weatherData) => { - Log.debug("[weatherprovider.yr] Got weather data from yr."); - let data; - if (weatherData) { - this.cacheWeatherData(weatherData); - data = weatherData; - } else { - //Undefined if unchanged - data = this.getWeatherDataFromCache(); - } - resolve(data); - }) - .catch((err) => { - Log.error("[weatherprovider.yr] getWeatherDataFromYr error: ", err); - if (weatherData) { - Log.warn("[weatherprovider.yr] Using outdated cached weather data."); - resolve(weatherData); - } else { - reject("Unable to get weather data from Yr."); - } - }) - .finally(() => { - localStorage.removeItem("yrIsFetchingWeatherData"); - }); - } - }, - - weatherDataIsValid (weatherData) { - return ( - weatherData - && weatherData.timeout - && 0 < moment(weatherData.timeout).diff(moment()) - && (!weatherData.geometry || !weatherData.geometry.coordinates || !weatherData.geometry.coordinates.length < 2 || (weatherData.geometry.coordinates[0] === this.config.lat && weatherData.geometry.coordinates[1] === this.config.lon)) - ); - }, - - getWeatherDataFromCache () { - const weatherData = localStorage.getItem("weatherData"); - if (weatherData) { - return JSON.parse(weatherData); - } else { - return undefined; - } - }, - - getWeatherDataFromYr (currentDataFetchedAt) { - const requestHeaders = [{ name: "Accept", value: "application/json" }]; - if (currentDataFetchedAt) { - requestHeaders.push({ name: "If-Modified-Since", value: currentDataFetchedAt }); - } - - const expectedResponseHeaders = ["expires", "date"]; - - return this.fetchData(this.getForecastUrl(), "json", requestHeaders, expectedResponseHeaders) - .then((data) => { - if (!data || !data.headers) return data; - data.timeout = data.headers.find((header) => header.name === "expires").value; - data.downloadedAt = data.headers.find((header) => header.name === "date").value; - data.headers = undefined; - return data; - }) - .catch((err) => { - Log.error("[weatherprovider.yr] Could not load weather data.", err); - throw new Error(err); - }); - }, - - getConfigOptions () { - if (!this.config.lat) { - Log.error("[weatherprovider.yr] Latitude not provided."); - throw new Error("Latitude not provided."); - } - if (!this.config.lon) { - Log.error("[weatherprovider.yr] Longitude not provided."); - throw new Error("Longitude not provided."); - } - - let lat = this.config.lat.toString(); - let lon = this.config.lon.toString(); - const altitude = this.config.altitude ?? 0; - return { lat, lon, altitude }; - }, - - getForecastUrl () { - let { lat, lon, altitude } = this.getConfigOptions(); - - if (lat.includes(".") && lat.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); - const latParts = lat.split("."); - lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; - } - if (lon.includes(".") && lon.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); - const lonParts = lon.split("."); - lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; - } - - return `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?&altitude=${altitude}&lat=${lat}&lon=${lon}`; - }, - - cacheWeatherData (weatherData) { - localStorage.setItem("weatherData", JSON.stringify(weatherData)); - }, - - getStellarData () { - - /* - * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. - * This is to avoid multiple similar calls to the API. - */ - return new Promise((resolve, reject) => { - let shouldWait = localStorage.getItem("yrIsFetchingStellarData"); - if (shouldWait) { - const checkForGo = setInterval(function () { - shouldWait = localStorage.getItem("yrIsFetchingStellarData"); - }, 100); - setTimeout(function () { - clearInterval(checkForGo); - shouldWait = false; - }, 5000); //Assume other fetch finished but failed to remove lock - const attemptFetchWeather = setInterval(() => { - if (!shouldWait) { - clearInterval(checkForGo); - clearInterval(attemptFetchWeather); - this.getStellarDataFromYrOrCache(resolve, reject); - } - }, 100); - } else { - this.getStellarDataFromYrOrCache(resolve, reject); - } - }); - }, - - getStellarDataFromYrOrCache (resolve, reject) { - localStorage.setItem("yrIsFetchingStellarData", "true"); - - let stellarData = this.getStellarDataFromCache(); - const today = moment().format("YYYY-MM-DD"); - const tomorrow = moment().add(1, "days").format("YYYY-MM-DD"); - if (stellarData && stellarData.today && stellarData.today.date === today && stellarData.tomorrow && stellarData.tomorrow.date === tomorrow) { - Log.debug("[weatherprovider.yr] Stellar data found in cache."); - localStorage.removeItem("yrIsFetchingStellarData"); - resolve(stellarData); - } else if (stellarData && stellarData.tomorrow && stellarData.tomorrow.date === today) { - Log.debug("[weatherprovider.yr] Stellar data for today found in cache, but not for tomorrow."); - stellarData.today = stellarData.tomorrow; - this.getStellarDataFromYr(tomorrow) - .then((data) => { - if (data) { - data.date = tomorrow; - stellarData.tomorrow = data; - this.cacheStellarData(stellarData); - resolve(stellarData); - } else { - reject(`No stellar data returned from Yr for ${tomorrow}`); - } - }) - .catch((err) => { - Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err); - reject(`Unable to get stellar data from Yr for ${tomorrow}`); - }) - .finally(() => { - localStorage.removeItem("yrIsFetchingStellarData"); - }); - } else { - this.getStellarDataFromYr(today, 2) - .then((stellarData) => { - if (stellarData) { - const data = { - today: stellarData - }; - data.tomorrow = Object.assign({}, data.today); - data.today.date = today; - data.tomorrow.date = tomorrow; - this.cacheStellarData(data); - resolve(data); - } else { - Log.error(`[weatherprovider.yr] Something went wrong when fetching stellar data. Responses: ${stellarData}`); - reject(stellarData); - } - }) - .catch((err) => { - Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err); - reject("Unable to get stellar data from Yr."); - }) - .finally(() => { - localStorage.removeItem("yrIsFetchingStellarData"); - }); - } - }, - - getStellarDataFromCache () { - const stellarData = localStorage.getItem("stellarData"); - if (stellarData) { - return JSON.parse(stellarData); - } else { - return undefined; - } - }, - - getStellarDataFromYr (date, days = 1) { - const requestHeaders = [{ name: "Accept", value: "application/json" }]; - return this.fetchData(this.getStellarDataUrl(date, days), "json", requestHeaders) - .then((data) => { - Log.debug("[weatherprovider.yr] Got stellar data from yr."); - return data; - }) - .catch((err) => { - Log.error("[weatherprovider.yr] Could not load weather data.", err); - throw new Error(err); - }); - }, - - getStellarDataUrl (date, days) { - let { lat, lon, altitude } = this.getConfigOptions(); - - if (lat.includes(".") && lat.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); - const latParts = lat.split("."); - lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; - } - if (lon.includes(".") && lon.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); - const lonParts = lon.split("."); - lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; - } - - let utcOffset = moment().utcOffset() / 60; - let utcOffsetPrefix = "%2B"; - if (utcOffset < 0) { - utcOffsetPrefix = "-"; - } - utcOffset = Math.abs(utcOffset); - let minutes = "00"; - if (utcOffset % 1 !== 0) { - minutes = "30"; - } - let hours = Math.floor(utcOffset).toString(); - if (hours.length < 2) { - hours = `0${hours}`; - } - return `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${date}&offset=${utcOffsetPrefix}${hours}%3A${minutes}`; - }, - - cacheStellarData (data) { - localStorage.setItem("stellarData", JSON.stringify(data)); - }, - - getWeatherDataFrom (forecast, stellarData, units) { - const weather = new WeatherObject(); - - weather.date = moment(forecast.time); - weather.windSpeed = forecast.data.instant.details.wind_speed; - weather.windFromDirection = forecast.data.instant.details.wind_from_direction; - weather.temperature = forecast.data.instant.details.air_temperature; - weather.minTemperature = forecast.minTemperature; - weather.maxTemperature = forecast.maxTemperature; - weather.weatherType = forecast.weatherType; - weather.humidity = forecast.data.instant.details.relative_humidity; - weather.precipitationAmount = forecast.precipitationAmount; - weather.precipitationProbability = forecast.precipitationProbability; - weather.precipitationUnits = units.precipitation_amount; - - weather.sunrise = stellarData?.today?.properties?.sunrise?.time; - weather.sunset = stellarData?.today?.properties?.sunset?.time; - - return weather; - }, - - convertWeatherType (weatherType, weatherTime) { - const weatherHour = moment(weatherTime).format("HH"); - - const weatherTypes = { - clearsky_day: "day-sunny", - clearsky_night: "night-clear", - clearsky_polartwilight: weatherHour < 14 ? "sunrise" : "sunset", - cloudy: "cloudy", - fair_day: "day-sunny-overcast", - fair_night: "night-alt-partly-cloudy", - fair_polartwilight: "day-sunny-overcast", - fog: "fog", - heavyrain: "rain", // Possibly raindrops or raindrop - heavyrainandthunder: "thunderstorm", - heavyrainshowers_day: "day-rain", - heavyrainshowers_night: "night-alt-rain", - heavyrainshowers_polartwilight: "day-rain", - heavyrainshowersandthunder_day: "day-thunderstorm", - heavyrainshowersandthunder_night: "night-alt-thunderstorm", - heavyrainshowersandthunder_polartwilight: "day-thunderstorm", - heavysleet: "sleet", - heavysleetandthunder: "day-sleet-storm", - heavysleetshowers_day: "day-sleet", - heavysleetshowers_night: "night-alt-sleet", - heavysleetshowers_polartwilight: "day-sleet", - heavysleetshowersandthunder_day: "day-sleet-storm", - heavysleetshowersandthunder_night: "night-alt-sleet-storm", - heavysleetshowersandthunder_polartwilight: "day-sleet-storm", - heavysnow: "snow-wind", - heavysnowandthunder: "day-snow-thunderstorm", - heavysnowshowers_day: "day-snow-wind", - heavysnowshowers_night: "night-alt-snow-wind", - heavysnowshowers_polartwilight: "day-snow-wind", - heavysnowshowersandthunder_day: "day-snow-thunderstorm", - heavysnowshowersandthunder_night: "night-alt-snow-thunderstorm", - heavysnowshowersandthunder_polartwilight: "day-snow-thunderstorm", - lightrain: "rain-mix", - lightrainandthunder: "thunderstorm", - lightrainshowers_day: "day-rain-mix", - lightrainshowers_night: "night-alt-rain-mix", - lightrainshowers_polartwilight: "day-rain-mix", - lightrainshowersandthunder_day: "thunderstorm", - lightrainshowersandthunder_night: "thunderstorm", - lightrainshowersandthunder_polartwilight: "thunderstorm", - lightsleet: "day-sleet", - lightsleetandthunder: "day-sleet-storm", - lightsleetshowers_day: "day-sleet", - lightsleetshowers_night: "night-alt-sleet", - lightsleetshowers_polartwilight: "day-sleet", - lightsnow: "snowflake-cold", - lightsnowandthunder: "day-snow-thunderstorm", - lightsnowshowers_day: "day-snow-wind", - lightsnowshowers_night: "night-alt-snow-wind", - lightsnowshowers_polartwilight: "day-snow-wind", - lightssleetshowersandthunder_day: "day-sleet-storm", - lightssleetshowersandthunder_night: "night-alt-sleet-storm", - lightssleetshowersandthunder_polartwilight: "day-sleet-storm", - lightssnowshowersandthunder_day: "day-snow-thunderstorm", - lightssnowshowersandthunder_night: "night-alt-snow-thunderstorm", - lightssnowshowersandthunder_polartwilight: "day-snow-thunderstorm", - partlycloudy_day: "day-cloudy", - partlycloudy_night: "night-alt-cloudy", - partlycloudy_polartwilight: "day-cloudy", - rain: "rain", - rainandthunder: "thunderstorm", - rainshowers_day: "day-rain", - rainshowers_night: "night-alt-rain", - rainshowers_polartwilight: "day-rain", - rainshowersandthunder_day: "thunderstorm", - rainshowersandthunder_night: "lightning", - rainshowersandthunder_polartwilight: "thunderstorm", - sleet: "sleet", - sleetandthunder: "day-sleet-storm", - sleetshowers_day: "day-sleet", - sleetshowers_night: "night-alt-sleet", - sleetshowers_polartwilight: "day-sleet", - sleetshowersandthunder_day: "day-sleet-storm", - sleetshowersandthunder_night: "night-alt-sleet-storm", - sleetshowersandthunder_polartwilight: "day-sleet-storm", - snow: "snowflake-cold", - snowandthunder: "lightning", - snowshowers_day: "day-snow-wind", - snowshowers_night: "night-alt-snow-wind", - snowshowers_polartwilight: "day-snow-wind", - snowshowersandthunder_day: "day-snow-thunderstorm", - snowshowersandthunder_night: "night-alt-snow-thunderstorm", - snowshowersandthunder_polartwilight: "day-snow-thunderstorm" - }; - - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - }, - - getForecastForXHoursFrom (weather) { - if (this.config.currentForecastHours === 1) { - if (weather.next_1_hours) { - return weather.next_1_hours; - } else if (weather.next_6_hours) { - return weather.next_6_hours; - } else { - return weather.next_12_hours; - } - } else if (this.config.currentForecastHours === 6) { - if (weather.next_6_hours) { - return weather.next_6_hours; - } else if (weather.next_12_hours) { - return weather.next_12_hours; - } else { - return weather.next_1_hours; - } - } else { - if (weather.next_12_hours) { - return weather.next_12_hours; - } else if (weather.next_6_hours) { - return weather.next_6_hours; - } else { - return weather.next_1_hours; - } - } - }, - - fetchWeatherHourly () { - this.getWeatherForecast("hourly") - .then((forecast) => { - this.setWeatherHourly(forecast); - this.updateAvailable(); - }) - .catch((error) => { - Log.error("[weatherprovider.yr] fetchWeatherHourly error: ", error); - this.updateAvailable(); - }); - }, - - async getWeatherForecast (type) { - const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]); - if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { - Log.error("[weatherprovider.yr] No weather data available."); - return; - } - if (!stellarData) { - Log.warn("[weatherprovider.yr] No stellar data available."); - } - let forecasts; - switch (type) { - case "hourly": - forecasts = this.getHourlyForecastFrom(weatherData); - break; - case "daily": - default: - forecasts = this.getDailyForecastFrom(weatherData); - break; - } - const series = []; - for (const forecast of forecasts) { - series.push(this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units)); - } - return series; - }, - - getHourlyForecastFrom (weatherData) { - const series = []; - - const now = moment({ - year: moment().year(), - month: moment().month(), - day: moment().date(), - hour: moment().hour() - }); - for (const forecast of weatherData.properties.timeseries) { - if (now.isAfter(moment(forecast.time))) continue; - - forecast.symbol = forecast.data.next_1_hours?.summary?.symbol_code; - forecast.precipitationAmount = forecast.data.next_1_hours?.details?.precipitation_amount; - forecast.precipitationProbability = forecast.data.next_1_hours?.details?.probability_of_precipitation; - forecast.minTemperature = forecast.data.next_1_hours?.details?.air_temperature_min; - forecast.maxTemperature = forecast.data.next_1_hours?.details?.air_temperature_max; - forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); - series.push(forecast); - } - return series; - }, - - getDailyForecastFrom (weatherData) { - const series = []; - - const days = weatherData.properties.timeseries.reduce(function (days, forecast) { - const date = moment(forecast.time).format("YYYY-MM-DD"); - days[date] = days[date] || []; - days[date].push(forecast); - return days; - }, Object.create(null)); - - Object.keys(days).forEach(function (time) { - let minTemperature = undefined; - let maxTemperature = undefined; - - //Default to first entry - let forecast = days[time][0]; - forecast.symbol = forecast.data.next_12_hours?.summary?.symbol_code; - forecast.precipitation = forecast.data.next_12_hours?.details?.precipitation_amount; - - //Coming days - let forecastDiffToEight = undefined; - for (const timeseries of days[time]) { - if (!timeseries.data.next_6_hours) continue; //next_6_hours has the most data - - if (!minTemperature || timeseries.data.next_6_hours.details.air_temperature_min < minTemperature) minTemperature = timeseries.data.next_6_hours.details.air_temperature_min; - if (!maxTemperature || maxTemperature < timeseries.data.next_6_hours.details.air_temperature_max) maxTemperature = timeseries.data.next_6_hours.details.air_temperature_max; - - let closestTime = Math.abs(moment(timeseries.time).local().set({ hour: 8, minute: 0, second: 0, millisecond: 0 }).diff(moment(timeseries.time).local())); - if ((forecastDiffToEight === undefined || closestTime < forecastDiffToEight) && timeseries.data.next_12_hours) { - forecastDiffToEight = closestTime; - forecast = timeseries; - } - } - const forecastXHours = forecast.data.next_12_hours ?? forecast.data.next_6_hours ?? forecast.data.next_1_hours; - if (forecastXHours) { - forecast.symbol = forecastXHours.summary?.symbol_code; - forecast.precipitationAmount = forecastXHours.details?.precipitation_amount ?? forecast.data.next_6_hours?.details?.precipitation_amount; // 6 hours is likely to have precipitation amount even if 12 hours does not - forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; - forecast.minTemperature = minTemperature; - forecast.maxTemperature = maxTemperature; - - series.push(forecast); - } - }); - for (const forecast of series) { - forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); - } - return series; - }, - - fetchWeatherForecast () { - this.getWeatherForecast("daily") - .then((forecast) => { - this.setWeatherForecast(forecast); - this.updateAvailable(); - }) - .catch((error) => { - Log.error("[weatherprovider.yr] fetchWeatherForecast error: ", error); - this.updateAvailable(); - }); - } -}); diff --git a/modules/default/weather/weatherprovider.js b/modules/default/weather/weatherprovider.js deleted file mode 100644 index 629d7e19d1..0000000000 --- a/modules/default/weather/weatherprovider.js +++ /dev/null @@ -1,165 +0,0 @@ -/* global Class, performWebRequest, OverrideWrapper */ - -// This class is the blueprint for a weather provider. -const WeatherProvider = Class.extend({ - // Weather Provider Properties - providerName: null, - defaults: {}, - - // The following properties have accessor methods. - // Try to not access them directly. - currentWeatherObject: null, - weatherForecastArray: null, - weatherHourlyArray: null, - fetchedLocationName: null, - - // The following properties will be set automatically. - // You do not need to overwrite these properties. - config: null, - delegate: null, - providerIdentifier: null, - - // Weather Provider Methods - // All the following methods can be overwritten, although most are good as they are. - - // Called when a weather provider is initialized. - init (config) { - this.config = config; - Log.info(`[weatherprovider] ${this.providerName} initialized.`); - }, - - // Called to set the config, this config is the same as the weather module's config. - setConfig (config) { - this.config = config; - Log.info(`[weatherprovider] ${this.providerName} config set.`, this.config); - }, - - // Called when the weather provider is about to start. - start () { - Log.info(`[weatherprovider] ${this.providerName} started.`); - }, - - // This method should start the API request to fetch the current weather. - // This method should definitely be overwritten in the provider. - fetchCurrentWeather () { - Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchCurrentWeather method.`); - }, - - // This method should start the API request to fetch the weather forecast. - // This method should definitely be overwritten in the provider. - fetchWeatherForecast () { - Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherForecast method.`); - }, - - // This method should start the API request to fetch the weather hourly. - // This method should definitely be overwritten in the provider. - fetchWeatherHourly () { - Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherHourly method.`); - }, - - // This returns a WeatherDay object for the current weather. - currentWeather () { - return this.currentWeatherObject; - }, - - // This returns an array of WeatherDay objects for the weather forecast. - weatherForecast () { - return this.weatherForecastArray; - }, - - // This returns an object containing WeatherDay object(s) depending on the type of call. - weatherHourly () { - return this.weatherHourlyArray; - }, - - // This returns the name of the fetched location or an empty string. - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - // Set the currentWeather and notify the delegate that new information is available. - setCurrentWeather (currentWeatherObject) { - // We should check here if we are passing a WeatherDay - this.currentWeatherObject = currentWeatherObject; - }, - - // Set the weatherForecastArray and notify the delegate that new information is available. - setWeatherForecast (weatherForecastArray) { - // We should check here if we are passing a WeatherDay - this.weatherForecastArray = weatherForecastArray; - }, - - // Set the weatherHourlyArray and notify the delegate that new information is available. - setWeatherHourly (weatherHourlyArray) { - this.weatherHourlyArray = weatherHourlyArray; - }, - - // Set the fetched location name. - setFetchedLocation (name) { - this.fetchedLocationName = name; - }, - - // Notify the delegate that new weather is available. - updateAvailable () { - this.delegate.updateAvailable(this); - }, - - /** - * A convenience function to make requests. - * @param {string} url the url to fetch from - * @param {string} type what content-type to expect in the response, can be "json" or "xml" - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @returns {Promise} resolved when the fetch is done - */ - async fetchData (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) { - const mockData = this.config.mockData; - if (mockData) { - const data = mockData.substring(1, mockData.length - 1); - return JSON.parse(data); - } - const useCorsProxy = typeof this.config.useCorsProxy !== "undefined" && this.config.useCorsProxy; - return performWebRequest(url, type, useCorsProxy, requestHeaders, expectedResponseHeaders, config.basePath); - } -}); - -/** - * Collection of registered weather providers. - */ -WeatherProvider.providers = []; - -/** - * Static method to register a new weather provider. - * @param {string} providerIdentifier The name of the weather provider - * @param {object} providerDetails The details of the weather provider - */ -WeatherProvider.register = function (providerIdentifier, providerDetails) { - WeatherProvider.providers[providerIdentifier.toLowerCase()] = WeatherProvider.extend(providerDetails); -}; - -/** - * Static method to initialize a new weather provider. - * @param {string} providerIdentifier The name of the weather provider - * @param {object} delegate The weather module - * @returns {object} The new weather provider - */ -WeatherProvider.initialize = function (providerIdentifier, delegate) { - const pi = providerIdentifier.toLowerCase(); - - const provider = new WeatherProvider.providers[pi](); - const config = Object.assign({}, provider.defaults, delegate.config); - - provider.delegate = delegate; - provider.setConfig(config); - - provider.providerIdentifier = pi; - if (!provider.providerName) { - provider.providerName = pi; - } - - if (config.allowOverrideNotification) { - return new OverrideWrapper(provider); - } - - return provider; -}; diff --git a/package-lock.json b/package-lock.json index 9078f6441b..1234f28d45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,111 +1,111 @@ { "name": "magicmirror", - "version": "2.34.0", + "version": "2.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "magicmirror", - "version": "2.34.0", + "version": "2.35.0", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@fontsource/roboto": "^5.2.9", + "@fontsource/roboto": "^5.2.10", "@fontsource/roboto-condensed": "^5.2.8", - "@fortawesome/fontawesome-free": "^7.1.0", - "ajv": "^8.17.1", + "@fortawesome/fontawesome-free": "^7.2.0", + "ajv": "^8.18.0", "animate.css": "^4.1.1", - "console-stamp": "^3.1.2", - "croner": "^9.1.0", - "envsub": "^4.1.0", - "eslint": "^9.39.2", + "croner": "^10.0.1", + "eslint": "^10.1.0", "express": "^5.2.1", "feedme": "^2.0.2", + "globals": "^17.4.0", "helmet": "^8.1.0", "html-to-text": "^9.0.5", - "iconv-lite": "^0.7.1", + "iconv-lite": "^0.7.2", "ipaddr.js": "^2.3.0", "moment": "^2.30.1", - "moment-timezone": "^0.6.0", - "node-ical": "^0.22.1", + "moment-timezone": "^0.6.1", + "node-ical": "^0.25.6", "nunjucks": "^3.2.4", "pm2": "^6.0.14", "socket.io": "^4.8.3", "suncalc": "^1.9.0", - "systeminformation": "^5.28.2", - "undici": "^7.16.0", + "systeminformation": "^5.31.5", + "undici": "^7.24.6", "weathericons": "^2.1.0" }, "devDependencies": { - "@stylistic/eslint-plugin": "^5.6.1", - "@vitest/coverage-v8": "^4.0.16", - "@vitest/ui": "^4.0.16", - "cspell": "^9.4.0", - "eslint-plugin-import-x": "^4.16.1", - "eslint-plugin-jsdoc": "^61.5.0", - "eslint-plugin-package-json": "^0.85.0", - "eslint-plugin-playwright": "^2.4.0", - "eslint-plugin-vitest": "^0.5.4", + "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", + "@vitest/coverage-v8": "^4.1.2", + "@vitest/eslint-plugin": "^1.6.14", + "@vitest/ui": "^4.1.2", + "cspell": "^9.7.0", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-jsdoc": "^62.8.1", + "eslint-plugin-package-json": "^0.91.1", + "eslint-plugin-playwright": "^2.10.1", "express-basic-auth": "^1.2.1", "husky": "^9.1.7", - "jsdom": "^27.4.0", - "lint-staged": "^16.2.7", - "markdownlint-cli2": "^0.20.0", - "playwright": "^1.57.0", - "prettier": "^3.7.4", + "jsdom": "^29.0.1", + "lint-staged": "^16.4.0", + "markdownlint-cli2": "^0.22.0", + "msw": "^2.12.14", + "playwright": "^1.58.2", + "prettier": "^3.8.1", "prettier-plugin-jinja-template": "^2.1.0", - "stylelint": "^16.26.1", - "stylelint-config-standard": "^39.0.1", + "stylelint": "^17.6.0", + "stylelint-config-standard": "^40.0.0", "stylelint-prettier": "^5.0.3", - "vitest": "^4.0.16" + "vitest": "^4.1.2" }, "engines": { "node": ">=22.21.1 <23 || >=24" }, "optionalDependencies": { - "electron": "^39.2.7" + "electron": "^41.1.0" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.30", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", - "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", - "dev": true, - "license": "MIT" - }, "node_modules/@altano/repository-tools": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@altano/repository-tools/-/repository-tools-2.0.1.tgz", - "integrity": "sha512-YE/52CkFtb+YtHPgbWPai7oo5N9AKnMuP5LM+i2AG7G1H2jdYBCO1iDnkDE3dZ3C1MIgckaF+d5PNRulgt0bdw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@altano/repository-tools/-/repository-tools-2.0.3.tgz", + "integrity": "sha512-cSR/ZYDF6Wp9OeAJMyLYYN1GenAAhV17W+w38ELP+3c5Ltsy9jkkCymi33nz/qnXyef3n6Fbr1h2yt3dvUN5sQ==", "dev": true, "license": "ISC" }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", + "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", - "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -116,13 +116,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -158,13 +158,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -174,9 +174,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -197,40 +197,53 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz", - "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", + "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/utils": "^2.3.3", - "@keyv/bigmap": "^1.3.0", - "hookified": "^1.14.0", - "keyv": "^5.5.5" + "@cacheable/utils": "^2.4.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" } }, "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.0.tgz", - "integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", "dev": true, "license": "MIT", "dependencies": { - "hashery": "^1.2.0", - "hookified": "^1.13.0" + "hashery": "^1.4.0", + "hookified": "^1.15.0" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "keyv": "^5.5.4" + "keyv": "^5.6.0" } }, "node_modules/@cacheable/memory/node_modules/keyv": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", - "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { @@ -238,20 +251,20 @@ } }, "node_modules/@cacheable/utils": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.3.tgz", - "integrity": "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", "dev": true, "license": "MIT", "dependencies": { - "hashery": "^1.3.0", - "keyv": "^5.5.5" + "hashery": "^1.5.1", + "keyv": "^5.6.0" } }, "node_modules/@cacheable/utils/node_modules/keyv": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", - "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { @@ -259,64 +272,64 @@ } }, "node_modules/@cspell/cspell-bundled-dicts": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.4.0.tgz", - "integrity": "sha512-Hm2gpMg/lRv4fKtiO2NfBiaJdFZVVb1V1a+IVhlD9qCuObLhCt60Oze2kD1dQzhbaIX756cs/eyxa5bQ5jihhQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.7.0.tgz", + "integrity": "sha512-s7h1vo++Q3AsfQa3cs0u/KGwm3SYInuIlC4kjlCBWjQmb4KddiZB5O1u0+3TlA7GycHb5M4CR7MDfHUICgJf+w==", "dev": true, "license": "MIT", "dependencies": { "@cspell/dict-ada": "^4.1.1", "@cspell/dict-al": "^1.1.1", - "@cspell/dict-aws": "^4.0.16", + "@cspell/dict-aws": "^4.0.17", "@cspell/dict-bash": "^4.2.2", - "@cspell/dict-companies": "^3.2.7", - "@cspell/dict-cpp": "^6.0.15", + "@cspell/dict-companies": "^3.2.10", + "@cspell/dict-cpp": "^7.0.2", "@cspell/dict-cryptocurrencies": "^5.0.5", - "@cspell/dict-csharp": "^4.0.7", - "@cspell/dict-css": "^4.0.18", - "@cspell/dict-dart": "^2.3.1", - "@cspell/dict-data-science": "^2.0.12", - "@cspell/dict-django": "^4.1.5", - "@cspell/dict-docker": "^1.1.16", - "@cspell/dict-dotnet": "^5.0.10", + "@cspell/dict-csharp": "^4.0.8", + "@cspell/dict-css": "^4.0.19", + "@cspell/dict-dart": "^2.3.2", + "@cspell/dict-data-science": "^2.0.13", + "@cspell/dict-django": "^4.1.6", + "@cspell/dict-docker": "^1.1.17", + "@cspell/dict-dotnet": "^5.0.12", "@cspell/dict-elixir": "^4.0.8", - "@cspell/dict-en_us": "^4.4.24", - "@cspell/dict-en-common-misspellings": "^2.1.8", - "@cspell/dict-en-gb-mit": "^3.1.14", - "@cspell/dict-filetypes": "^3.0.14", + "@cspell/dict-en_us": "^4.4.29", + "@cspell/dict-en-common-misspellings": "^2.1.12", + "@cspell/dict-en-gb-mit": "^3.1.18", + "@cspell/dict-filetypes": "^3.0.15", "@cspell/dict-flutter": "^1.1.1", "@cspell/dict-fonts": "^4.0.5", "@cspell/dict-fsharp": "^1.1.1", - "@cspell/dict-fullstack": "^3.2.7", + "@cspell/dict-fullstack": "^3.2.8", "@cspell/dict-gaming-terms": "^1.1.2", - "@cspell/dict-git": "^3.0.7", - "@cspell/dict-golang": "^6.0.24", + "@cspell/dict-git": "^3.1.0", + "@cspell/dict-golang": "^6.0.26", "@cspell/dict-google": "^1.0.9", "@cspell/dict-haskell": "^4.0.6", - "@cspell/dict-html": "^4.0.13", - "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-html": "^4.0.14", + "@cspell/dict-html-symbol-entities": "^4.0.5", "@cspell/dict-java": "^5.0.12", "@cspell/dict-julia": "^1.1.1", "@cspell/dict-k8s": "^1.0.12", "@cspell/dict-kotlin": "^1.1.1", - "@cspell/dict-latex": "^4.0.4", + "@cspell/dict-latex": "^5.0.0", "@cspell/dict-lorem-ipsum": "^4.0.5", "@cspell/dict-lua": "^4.0.8", "@cspell/dict-makefile": "^1.0.5", - "@cspell/dict-markdown": "^2.0.13", - "@cspell/dict-monkeyc": "^1.0.11", - "@cspell/dict-node": "^5.0.8", - "@cspell/dict-npm": "^5.2.25", - "@cspell/dict-php": "^4.1.0", + "@cspell/dict-markdown": "^2.0.14", + "@cspell/dict-monkeyc": "^1.0.12", + "@cspell/dict-node": "^5.0.9", + "@cspell/dict-npm": "^5.2.34", + "@cspell/dict-php": "^4.1.1", "@cspell/dict-powershell": "^5.0.15", "@cspell/dict-public-licenses": "^2.0.15", - "@cspell/dict-python": "^4.2.23", + "@cspell/dict-python": "^4.2.25", "@cspell/dict-r": "^2.1.1", - "@cspell/dict-ruby": "^5.0.9", - "@cspell/dict-rust": "^4.0.12", - "@cspell/dict-scala": "^5.0.8", + "@cspell/dict-ruby": "^5.1.0", + "@cspell/dict-rust": "^4.1.2", + "@cspell/dict-scala": "^5.0.9", "@cspell/dict-shell": "^1.1.2", - "@cspell/dict-software-terms": "^5.1.15", + "@cspell/dict-software-terms": "^5.1.21", "@cspell/dict-sql": "^2.2.1", "@cspell/dict-svelte": "^1.0.7", "@cspell/dict-swift": "^2.0.6", @@ -330,22 +343,32 @@ } }, "node_modules/@cspell/cspell-json-reporter": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.4.0.tgz", - "integrity": "sha512-TpHY7t13xNhcZF9bwOfgVIhcyPDamMnxU/TBYhf4mPtXPLrZ5gBTg3UZh0/9Zn3naMjmJtngdsLvB2wai9xBlQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.7.0.tgz", + "integrity": "sha512-6xpGXlMtQA3hV2BCAQcPkpx9eI12I0o01i9eRqSSEDKtxuAnnrejbcCpL+5OboAjTp3/BSeNYSnhuWYLkSITWQ==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/cspell-types": "9.4.0" + "@cspell/cspell-types": "9.7.0" }, "engines": { "node": ">=20" } }, + "node_modules/@cspell/cspell-performance-monitor": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-9.7.0.tgz", + "integrity": "sha512-w1PZIFXuvjnC6mQHyYAFnrsn5MzKnEcEkcK1bj4OG00bAt7WX2VUA/eNNt9c1iHozCQ+FcRYlfbGxuBmNyzSgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18" + } + }, "node_modules/@cspell/cspell-pipe": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.4.0.tgz", - "integrity": "sha512-cI0sUe7SB99hJB1T6PhH/MpSrnml1kOekTCE+VH3Eb7zkVP5/mwJXs8BlufdvwBona+Cgkx6jeWlhFpxLc39Yg==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.7.0.tgz", + "integrity": "sha512-iiisyRpJciU9SOHNSi0ZEK0pqbEMFRatI/R4O+trVKb+W44p4MNGClLVRWPGUmsFbZKPJL3jDtz0wPlG0/JCZA==", "dev": true, "license": "MIT", "engines": { @@ -353,22 +376,22 @@ } }, "node_modules/@cspell/cspell-resolver": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.4.0.tgz", - "integrity": "sha512-o9gbbdXlhxG2rqtGqQ7xZ8MGDDsPLbskBnTeuA++ix4Ch/HdjrBNmKReIGAEqJPfP+JGgoEKqFISHUDKAJ/ygQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.7.0.tgz", + "integrity": "sha512-uiEgS238mdabDnwavo6HXt8K98jlh/jpm7NONroM9NTr9rzck2VZKD2kXEj85wDNMtRsRXNoywTjwQ8WTB6/+w==", "dev": true, "license": "MIT", "dependencies": { - "global-directory": "^4.0.1" + "global-directory": "^5.0.0" }, "engines": { "node": ">=20" } }, "node_modules/@cspell/cspell-service-bus": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.4.0.tgz", - "integrity": "sha512-UottRlFPN6FGUfojx5HtUPZTeYXg2rf2HvO/HLh0KicirVYO16vFxTevg9MyOvw1EXSsDRz8ECANjiE7fnzBCQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.7.0.tgz", + "integrity": "sha512-fkqtaCkg4jY/FotmzjhIavbXuH0AgUJxZk78Ktf4XlhqOZ4wDeUWrCf220bva4mh3TWiLx/ae9lIlpl59Vx6hA==", "dev": true, "license": "MIT", "engines": { @@ -376,15 +399,28 @@ } }, "node_modules/@cspell/cspell-types": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.4.0.tgz", - "integrity": "sha512-vSpd50OfmthBH0aRFRLA2zJFtwli3ntHA0WAOJ8tIMLUCJgF3udooRXFeX3wR8ri69C9mc3864LC4inyRC/E9w==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.7.0.tgz", + "integrity": "sha512-Tdfx4eH2uS+gv9V9NCr3Rz+c7RSS6ntXp3Blliud18ibRUlRxO9dTaOjG4iv4x0nAmMeedP1ORkEpeXSkh2QiQ==", "dev": true, "license": "MIT", "engines": { "node": ">=20" } }, + "node_modules/@cspell/cspell-worker": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-9.7.0.tgz", + "integrity": "sha512-cjEApFF0aOAa1vTUk+e7xP8ofK7iC7hsRzj1FmvvVQz8PoLWPRaq+1bT89ypPsZQvavqm5sIgb97S60/aW4TVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cspell-lib": "9.7.0" + }, + "engines": { + "node": ">=20.18" + } + }, "node_modules/@cspell/dict-ada": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", @@ -417,16 +453,16 @@ } }, "node_modules/@cspell/dict-companies": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.9.tgz", - "integrity": "sha512-y5GdU+LnuMhUE/WYwOYt7GcJdrpmV4KXE1oFb5toEsnGa2KzffUbS6lwPpeRBocQoqZj8jJYFtxoQ+2KVg++/A==", + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.11.tgz", + "integrity": "sha512-0cmafbcz2pTHXLd59eLR1gvDvN6aWAOM0+cIL4LLF9GX9yB2iKDNrKsvs4tJRqutoaTdwNFBbV0FYv+6iCtebQ==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-cpp": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-6.0.15.tgz", - "integrity": "sha512-N7MKK3llRNoBncygvrnLaGvmjo4xzVr5FbtAc9+MFGHK6/LeSySBupr1FM72XDaVSIsmBEe7sDYCHHwlI9Jb2w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.0.2.tgz", + "integrity": "sha512-dfbeERiVNeqmo/npivdR6rDiBCqZi3QtjH2Z0HFcXwpdj6i97dX1xaKyK2GUsO/p4u1TOv63Dmj5Vm48haDpuA==", "dev": true, "license": "MIT" }, @@ -445,9 +481,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-css": { - "version": "4.0.19", - "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.19.tgz", - "integrity": "sha512-VYHtPnZt/Zd/ATbW3rtexWpBnHUohUrQOHff/2JBhsVgxOrksAxJnLAO43Q1ayLJBJUUwNVo+RU0sx0aaysZfg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.1.tgz", + "integrity": "sha512-y/Vgo6qY08e1t9OqR56qjoFLBCpi4QfWMf2qzD1l9omRZwvSMQGRPz4x0bxkkkU4oocMAeztjzCsmLew//c/8w==", "dev": true, "license": "MIT" }, @@ -480,9 +516,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-dotnet": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.10.tgz", - "integrity": "sha512-ooar8BP/RBNP1gzYfJPStKEmpWy4uv/7JCq6FOnJLeD1yyfG3d/LFMVMwiJo+XWz025cxtkM3wuaikBWzCqkmg==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.13.tgz", + "integrity": "sha512-xPp7jMnFpOri7tzmqmm/dXMolXz1t2bhNqxYkOyMqXhvs08oc7BFs+EsbDY0X7hqiISgeFZGNqn0dOCr+ncPYw==", "dev": true, "license": "MIT" }, @@ -494,30 +530,30 @@ "license": "MIT" }, "node_modules/@cspell/dict-en_us": { - "version": "4.4.26", - "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.26.tgz", - "integrity": "sha512-rpjM87n2e3PN3mx9SbzQOIniEWUKewZj0xFA796Pzeu3gJlYsHsSkZZC6Jxdea2992EfrzJZYwJb+mjxa3gWGg==", + "version": "4.4.33", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.33.tgz", + "integrity": "sha512-zWftVqfUStDA37wO1ZNDN1qMJOfcxELa8ucHW8W8wBAZY3TK5Nb6deLogCK/IJi/Qljf30dwwuqqv84Qqle9Tw==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-en-common-misspellings": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.10.tgz", - "integrity": "sha512-+S10oo15G3Axz1W4FGmYNq9u0xxS6OhNl9dXY3qjYBOqhzfF3l1oM/TpkfH/1NH31r3GneuPVXKXT7y16qwJYA==", + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.12.tgz", + "integrity": "sha512-14Eu6QGqyksqOd4fYPuRb58lK1Va7FQK9XxFsRKnZU8LhL3N+kj7YKDW+7aIaAN/0WGEqslGP6lGbQzNti8Akw==", "dev": true, "license": "CC BY-SA 4.0" }, "node_modules/@cspell/dict-en-gb-mit": { - "version": "3.1.15", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.15.tgz", - "integrity": "sha512-iF1KPSULjpAbtmPFTzyykytQPliBw5Qc7EVt5a/cdpJ/WBnosjBKHj0/svESc+enQoxq7bMcmhL9qJeGHQAWyQ==", + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.22.tgz", + "integrity": "sha512-xE5Vg6gGdMkZ1Ep6z9SJMMioGkkT1GbxS5Mm0U3Ey1/H68P0G7cJcyiVr1CARxFbLqKE4QUpoV1o6jz1Z5Yl9Q==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-filetypes": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.15.tgz", - "integrity": "sha512-uDMeqYlLlK476w/muEFQGBy9BdQWS0mQ7BJiy/iQv5XUWZxE2O54ZQd9nW8GyQMzAgoyg5SG4hf9l039Qt66oA==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz", + "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==", "dev": true, "license": "MIT" }, @@ -529,9 +565,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-fonts": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", - "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz", + "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==", "dev": true, "license": "MIT" }, @@ -543,9 +579,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-fullstack": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.7.tgz", - "integrity": "sha512-IxEk2YAwAJKYCUEgEeOg3QvTL4XLlyArJElFuMQevU1dPgHgzWElFevN5lsTFnvMFA1riYsVinqJJX0BanCFEg==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz", + "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==", "dev": true, "license": "MIT" }, @@ -557,16 +593,16 @@ "license": "MIT" }, "node_modules/@cspell/dict-git": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.0.7.tgz", - "integrity": "sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.1.0.tgz", + "integrity": "sha512-KEt9zGkxqGy2q1nwH4CbyqTSv5nadpn8BAlDnzlRcnL0Xb3LX9xTgSGShKvzb0bw35lHoYyLWN2ZKAqbC4pgGQ==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-golang": { - "version": "6.0.25", - "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.25.tgz", - "integrity": "sha512-Q0mkUj1mFN1P5LZoKBeTLOQehlHMYv62K0Px9FS7qykSvZjBz44bhCezJuepTPCiCFqmwQgT2fc3Ixw+fhO6pQ==", + "version": "6.0.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.26.tgz", + "integrity": "sha512-YKA7Xm5KeOd14v5SQ4ll6afe9VSy3a2DWM7L9uBq4u3lXToRBQ1W5PRa+/Q9udd+DTURyVVnQ+7b9cnOlNxaRg==", "dev": true, "license": "MIT" }, @@ -585,9 +621,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-html": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.14.tgz", - "integrity": "sha512-2bf7n+kS92g+cMKV0wr9o/Oq9n8JzU7CcrB96gIh2GHgnF+0xDOqO2W/1KeFAqOfqosoOVE48t+4dnEMkkoJ2Q==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", + "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "dev": true, "license": "MIT" }, @@ -627,9 +663,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-latex": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-4.0.4.tgz", - "integrity": "sha512-YdTQhnTINEEm/LZgTzr9Voz4mzdOXH7YX+bSFs3hnkUHCUUtX/mhKgf1CFvZ0YNM2afjhQcmLaR9bDQVyYBvpA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.1.0.tgz", + "integrity": "sha512-qxT4guhysyBt0gzoliXYEBYinkAdEtR2M7goRaUH0a7ltCsoqqAeEV8aXYRIdZGcV77gYSobvu3jJL038tlPAw==", "dev": true, "license": "MIT" }, @@ -655,14 +691,14 @@ "license": "MIT" }, "node_modules/@cspell/dict-markdown": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.14.tgz", - "integrity": "sha512-uLKPNJsUcumMQTsZZgAK9RgDLyQhUz/uvbQTEkvF/Q4XfC1i/BnA8XrOrd0+Vp6+tPOKyA+omI5LRWfMu5K/Lw==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.16.tgz", + "integrity": "sha512-976RRqKv6cwhrxdFCQP2DdnBVB86BF57oQtPHy4Zbf4jF/i2Oy29MCrxirnOBalS1W6KQeto7NdfDXRAwkK4PQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@cspell/dict-css": "^4.0.19", - "@cspell/dict-html": "^4.0.14", + "@cspell/dict-css": "^4.1.1", + "@cspell/dict-html": "^4.0.15", "@cspell/dict-html-symbol-entities": "^4.0.5", "@cspell/dict-typescript": "^3.2.3" } @@ -675,23 +711,23 @@ "license": "MIT" }, "node_modules/@cspell/dict-node": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.8.tgz", - "integrity": "sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.9.tgz", + "integrity": "sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-npm": { - "version": "5.2.27", - "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.27.tgz", - "integrity": "sha512-REy2vRQ9BJkjoW8cEr8ewoJAZ0DsTh+TimJ58KgIG1d81caanNgdvKLSgDkPd8OlGxPfLKHe7o2TJuk/l7VqhA==", + "version": "5.2.38", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.38.tgz", + "integrity": "sha512-21ucGRPYYhr91C2cDBoMPTrcIOStQv33xOqJB0JLoC5LAs2Sfj9EoPGhGb+gIFVHz6Ia7JQWE2SJsOVFJD1wmg==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-php": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.0.tgz", - "integrity": "sha512-dTDeabyOj7eFvn2Q4Za3uVXM2+SzeFMqX8ly2P0XTo4AzbCmI2hulFD/QIADwWmwiRrInbbf8cxwFHNIYrXl4w==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.1.tgz", + "integrity": "sha512-EXelI+4AftmdIGtA8HL8kr4WlUE11OqCSVlnIgZekmTkEGSZdYnkFdiJ5IANSALtlQ1mghKjz+OFqVs6yowgWA==", "dev": true, "license": "MIT" }, @@ -703,16 +739,16 @@ "license": "MIT" }, "node_modules/@cspell/dict-public-licenses": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.15.tgz", - "integrity": "sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.16.tgz", + "integrity": "sha512-EQRrPvEOmwhwWezV+W7LjXbIBjiy6y/shrET6Qcpnk3XANTzfvWflf9PnJ5kId/oKWvihFy0za0AV1JHd03pSQ==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-python": { - "version": "4.2.24", - "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.24.tgz", - "integrity": "sha512-B1oXYTa0+3sKOvx/svwxFaT3MrkHJ7ZLWpA1N7ZyHoET7IJhLCwcfAu7DCTq1f24Wnd4t+ARJvPEmFbMx65VBw==", + "version": "4.2.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.26.tgz", + "integrity": "sha512-hbjN6BjlSgZOG2dA2DtvYNGBM5Aq0i0dHaZjMOI9K/9vRicVvKbcCiBSSrR3b+jwjhQL5ff7HwG5xFaaci0GQA==", "dev": true, "license": "MIT", "dependencies": { @@ -727,23 +763,23 @@ "license": "MIT" }, "node_modules/@cspell/dict-ruby": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.0.9.tgz", - "integrity": "sha512-H2vMcERMcANvQshAdrVx0XoWaNX8zmmiQN11dZZTQAZaNJ0xatdJoSqY8C8uhEMW89bfgpN+NQgGuDXW2vmXEw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.1.tgz", + "integrity": "sha512-LHrp84oEV6q1ZxPPyj4z+FdKyq1XAKYPtmGptrd+uwHbrF/Ns5+fy6gtSi7pS+uc0zk3JdO9w/tPK+8N1/7WUA==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-rust": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.1.0.tgz", - "integrity": "sha512-ysFxxKc3QjPWtPacbwxzz8sDOACHNShlhQpnBsDXAHN3LogmuBsQtfyuU30APqFjCOg9KwGciKYC/hcGxJCbiA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.1.2.tgz", + "integrity": "sha512-O1FHrumYcO+HZti3dHfBPUdnDFkI+nbYK3pxYmiM1sr+G0ebOd6qchmswS0Wsc6ZdEVNiPYJY/gZQR6jfW3uOg==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-scala": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.8.tgz", - "integrity": "sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.9.tgz", + "integrity": "sha512-AjVcVAELgllybr1zk93CJ5wSUNu/Zb5kIubymR/GAYkMyBdYFCZ3Zbwn4Zz8GJlFFAbazABGOu0JPVbeY59vGg==", "dev": true, "license": "MIT" }, @@ -755,9 +791,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-software-terms": { - "version": "5.1.18", - "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.18.tgz", - "integrity": "sha512-+RUM+DnRnGzDjnJrAEiEQnopPGBXQ5kUY9t38WdTVYVgkpIE0/dcMX+s5uAp7vvKezhU6gW+CGW5K5xdF2KKiw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.2.2.tgz", + "integrity": "sha512-0CaYd6TAsKtEoA7tNswm1iptEblTzEe3UG8beG2cpSTHk7afWIVMtJLgXDv0f/Li67Lf3Z1Jf3JeXR7GsJ2TRw==", "dev": true, "license": "MIT" }, @@ -811,13 +847,13 @@ "license": "MIT" }, "node_modules/@cspell/dynamic-import": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.4.0.tgz", - "integrity": "sha512-d2fjLjzrKGUIn5hWK8gMuyAh2pqXSxBqOHpU1jR3jxbrO3MilunKNijaSstv7CZn067Jpc36VfaKQodaXNZzUA==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.7.0.tgz", + "integrity": "sha512-Ws36IYvtS/8IN3x6K9dPLvTmaArodRJmzTn2Rkf2NaTnIYWhRuFzsP3SVVO59NN3fXswAEbmz5DSbVUe8bPZHg==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/url": "9.4.0", + "@cspell/url": "9.7.0", "import-meta-resolve": "^4.2.0" }, "engines": { @@ -825,19 +861,29 @@ } }, "node_modules/@cspell/filetypes": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.4.0.tgz", - "integrity": "sha512-RMrYHkvPF0tHVFM+T4voEhX9sfYQrd/mnNbf6+O4CWUyLCz4NQ5H9yOgEIJwEcLu4y3NESGXFef/Jn5xo0CUfg==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.7.0.tgz", + "integrity": "sha512-Ln9e/8wGOyTeL3DCCs6kwd18TSpTw3kxsANjTrzLDASrX4cNmAdvc9J5dcIuBHPaqOAnRQxuZbzUlpRh73Y24w==", "dev": true, "license": "MIT", "engines": { "node": ">=20" } }, + "node_modules/@cspell/rpc": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-9.7.0.tgz", + "integrity": "sha512-VnZ4ABgQeoS4RwofcePkDP7L6tf3Kh5D7LQKoyRM4R6XtfSsYefym6XKaRl3saGtthH5YyjgNJ0Tgdjen4wAAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18" + } + }, "node_modules/@cspell/strong-weak-map": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.4.0.tgz", - "integrity": "sha512-ui7mlXYmqElS/SmRubPBNWdkQVWgWbB6rjCurc+0owYXlnweItAMHTxC8mCWM/Au22SF1dB/JR8QBELFXLkTjQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.7.0.tgz", + "integrity": "sha512-5xbvDASjklrmy88O6gmGXgYhpByCXqOj5wIgyvwZe2l83T1bE+iOfGI4pGzZJ/mN+qTn1DNKq8BPBPtDgb7Q2Q==", "dev": true, "license": "MIT", "engines": { @@ -845,9 +891,9 @@ } }, "node_modules/@cspell/url": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.4.0.tgz", - "integrity": "sha512-nt88P6m20AaVbqMxsyPf8KqyWPaFEW2UANi0ijBxc2xTkD2KiUovxfZUYW6NMU9XBYZlovT5LztkEhst2yBcSA==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.7.0.tgz", + "integrity": "sha512-ZaaBr0pTvNxmyUbIn+nVPXPr383VqJzfUDMWicgTjJIeo2+T2hOq2kNpgpvTIrWtZrsZnSP8oXms1+sKTjcvkw==", "dev": true, "license": "MIT", "engines": { @@ -855,9 +901,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -871,13 +917,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -891,17 +937,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -915,21 +961,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -943,16 +989,16 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", - "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", "dev": true, "funding": [ { @@ -965,14 +1011,19 @@ } ], "license": "MIT-0", - "engines": { - "node": ">=18" + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } } }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -986,13 +1037,13 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/media-query-list-parser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", - "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", + "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", "dev": true, "funding": [ { @@ -1006,17 +1057,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "node_modules/@csstools/selector-resolve-nested": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", + "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", "dev": true, "funding": [ { @@ -1030,21 +1081,33 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "postcss-selector-parser": "^7.0.0" + "postcss-selector-parser": "^7.1.1" } }, - "node_modules/@dual-bundle/import-meta-resolve": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", - "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", + "node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/JounQin" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" } }, "node_modules/@electron/get": { @@ -1090,14 +1153,14 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, @@ -1110,9 +1173,9 @@ "optional": true }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, @@ -1129,9 +1192,9 @@ "optional": true }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, @@ -1148,20 +1211,30 @@ "optional": true }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.76.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.76.0.tgz", - "integrity": "sha512-g+RihtzFgGTx2WYCuTHbdOXJeAlGnROws0TeALx9ow/ZmOROOZkVg5wp/B44n0WJgI4SQFP1eWM2iRPlU2Y14w==", + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.84.0.tgz", + "integrity": "sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.46.0", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~6.10.0" + "@typescript-eslint/types": "^8.54.0", + "comment-parser": "1.4.5", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.1.1" }, "engines": { - "node": ">=20.11.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/jsdoccomment/node_modules/comment-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", + "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" } }, "node_modules/@es-joy/resolve.exports": { @@ -1174,626 +1247,148 @@ "node": ">=10" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@exodus/bytes": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz", - "integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@exodus/crypto": "^1.0.0-rc.4" + "@noble/hashes": "^1.8.0 || ^2.0.0" }, "peerDependenciesMeta": { - "@exodus/crypto": { + "@noble/hashes": { "optional": true } } }, "node_modules/@fontsource/roboto": { - "version": "5.2.9", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.9.tgz", - "integrity": "sha512-ZTkyHiPk74B/aj8BZWbsxD5Yu+Lq+nR64eV4wirlrac2qXR7jYk2h6JlLYuOuoruTkGQWNw2fMuKNavw7/rg0w==", + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz", + "integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -1809,9 +1404,9 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.1.0.tgz", - "integrity": "sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", + "integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==", "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", "engines": { "node": ">=6" @@ -1865,27 +1460,171 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", "dev": true, "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=18" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dev": true, "license": "MIT", "dependencies": { - "@isaacs/balanced-match": "^4.0.1" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": "20 || >=22" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@inquirer/core/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@jridgewell/resolve-uri": { @@ -1916,6 +1655,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -1923,6 +1674,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1974,6 +1743,48 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, "node_modules/@pm2/agent": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@pm2/agent/-/agent-2.1.1.tgz", @@ -1994,6 +1805,21 @@ "ws": "~7.5.10" } }, + "node_modules/@pm2/agent/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/@pm2/agent/node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -2054,28 +1880,7 @@ "semver": "bin/semver.js" }, "engines": { - "node": ">=10" - } - }, - "node_modules/@pm2/agent/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=10" } }, "node_modules/@pm2/blessed": { @@ -2168,6 +1973,12 @@ "node": ">=10" } }, + "node_modules/@pm2/io/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/@pm2/js-api": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.0.tgz", @@ -2216,27 +2027,6 @@ "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", "license": "MIT" }, - "node_modules/@pm2/js-api/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@pm2/pm2-version-check": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", @@ -2253,24 +2043,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", "cpu": [ "arm64" ], @@ -2279,12 +2055,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", "cpu": [ "arm64" ], @@ -2293,12 +2072,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", "cpu": [ "x64" ], @@ -2307,26 +2089,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", "cpu": [ "x64" ], @@ -2335,26 +2106,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", "cpu": [ "arm" ], @@ -2363,138 +2123,135 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", - "cpu": [ - "loong64" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", "cpu": [ "arm64" ], @@ -2503,54 +2260,68 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", - "cpu": [ - "ia32" - ], + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", "cpu": [ "x64" ], @@ -2559,7 +2330,17 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", @@ -2627,14 +2408,14 @@ "license": "MIT" }, "node_modules/@stylistic/eslint-plugin": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.6.1.tgz", - "integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.0", - "@typescript-eslint/types": "^8.47.0", + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", @@ -2644,7 +2425,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "peerDependencies": { - "eslint": ">=9.0.0" + "eslint": "^9.0.0 || ^10.0.0" } }, "node_modules/@szmarczak/http-timer": { @@ -2719,9 +2500,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dev": true, "license": "MIT", "dependencies": { @@ -2735,6 +2516,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2742,9 +2529,9 @@ "license": "MIT" }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "license": "MIT", "optional": true }, @@ -2755,9 +2542,9 @@ "license": "MIT" }, "node_modules/@types/katex": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", - "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", "dev": true, "license": "MIT" }, @@ -2779,12 +2566,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", - "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/responselike": { @@ -2797,6 +2584,13 @@ "@types/node": "*" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -2804,6 +2598,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2814,44 +2617,38 @@ "@types/node": "*" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || >=20.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.50.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz", - "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2860,132 +2657,101 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2993,13 +2759,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3111,6 +2877,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3125,6 +2894,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3139,6 +2911,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3153,6 +2928,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3167,6 +2945,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3181,6 +2962,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3195,6 +2979,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3209,6 +2996,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3275,30 +3065,29 @@ ] }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", - "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.16", - "ast-v8-to-istanbul": "^0.3.8", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.16", - "vitest": "4.0.16" + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3306,32 +3095,63 @@ } } }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.14.tgz", + "integrity": "sha512-PXZ5ysw4eHU9h8nDtBvVcGC7Z2C/T9CFdheqSw1NNXFYqViojub0V9bgdYI67iBTOcra2mwD0EYldlY9bGPf2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", - "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", - "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.16", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3340,7 +3160,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3352,26 +3172,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", - "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", - "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.16", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -3379,13 +3199,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", - "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3394,9 +3215,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", - "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -3404,36 +3225,37 @@ } }, "node_modules/@vitest/ui": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz", - "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz", + "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.16", + "@vitest/utils": "4.1.2", "fflate": "^0.8.2", - "flatted": "^3.3.3", + "flatted": "^3.4.2", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.16" + "vitest": "4.1.2" } }, "node_modules/@vitest/utils": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", - "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3459,9 +3281,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3489,9 +3311,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3535,9 +3357,9 @@ } }, "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { @@ -3564,15 +3386,13 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -3601,9 +3421,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -3628,22 +3448,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -3651,37 +3455,6 @@ "dev": true, "license": "MIT" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3717,15 +3490,15 @@ "license": "0BSD" }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", - "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/astral-regex": { @@ -3744,36 +3517,15 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "18 || 20 || >=22" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -3797,9 +3549,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3827,12 +3579,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT" - }, "node_modules/bodec": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bodec/-/bodec-0.1.0.tgz", @@ -3840,9 +3586,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -3851,7 +3597,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -3872,13 +3618,15 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -3919,17 +3667,17 @@ } }, "node_modules/cacheable": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.1.tgz", - "integrity": "sha512-yr+FSHWn1ZUou5LkULX/S+jhfgfnLbuKQjE40tyEd4fxGZVMbBL5ifno0J0OauykS8UiCSgHi+DV/YD+rjFxFg==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", + "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/memory": "^2.0.6", - "@cacheable/utils": "^2.3.2", - "hookified": "^1.14.0", - "keyv": "^5.5.5", - "qified": "^0.5.3" + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.9.0" } }, "node_modules/cacheable-lookup": { @@ -3962,33 +3710,15 @@ } }, "node_modules/cacheable/node_modules/keyv": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", - "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4022,6 +3752,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4038,16 +3769,13 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -4069,19 +3797,6 @@ "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, - "node_modules/chalk-template/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -4219,6 +3934,21 @@ "node": ">=8.10.0" } }, + "node_modules/cli-tableau/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/cli-tableau/node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -4233,53 +3963,127 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=20" + "node": ">=8" } }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/clone-response": { @@ -4328,9 +4132,9 @@ "license": "MIT" }, "node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { @@ -4338,14 +4142,13 @@ } }, "node_modules/comment-json": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.1.tgz", - "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", "dev": true, "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { @@ -4353,34 +4156,15 @@ } }, "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", "dev": true, "license": "MIT", "engines": { "node": ">= 12.0.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/console-stamp": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/console-stamp/-/console-stamp-3.1.2.tgz", - "integrity": "sha512-ab66x3NxOTxPuq71dI6gXEiw2X6ql4Le5gZz0bm7FW3FSCB00eztra/oQUuCoCGlsyKOxtULnHwphzMrRtzMBg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "dateformat": "^4.6.3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -4403,6 +4187,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -4421,17 +4212,10 @@ "node": ">=6.6.0" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -4439,12 +4223,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4479,9 +4267,19 @@ } }, "node_modules/croner": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz", - "integrity": "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], "license": "MIT", "engines": { "node": ">=18.0" @@ -4502,30 +4300,32 @@ } }, "node_modules/cspell": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.4.0.tgz", - "integrity": "sha512-ZvXO+EY/G0/msu7jwRiVk0sXL/zB7DMJLBvjSUrK82uVbDoDxHwXxUuOz2UVnk2+J61//ldIZrjxVK8KMvaJlg==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.7.0.tgz", + "integrity": "sha512-ftxOnkd+scAI7RZ1/ksgBZRr0ouC7QRKtPQhD/PbLTKwAM62sSvRhE1bFsuW3VKBn/GilWzTjkJ40WmnDqH5iQ==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/cspell-json-reporter": "9.4.0", - "@cspell/cspell-pipe": "9.4.0", - "@cspell/cspell-types": "9.4.0", - "@cspell/dynamic-import": "9.4.0", - "@cspell/url": "9.4.0", + "@cspell/cspell-json-reporter": "9.7.0", + "@cspell/cspell-performance-monitor": "9.7.0", + "@cspell/cspell-pipe": "9.7.0", + "@cspell/cspell-types": "9.7.0", + "@cspell/cspell-worker": "9.7.0", + "@cspell/dynamic-import": "9.7.0", + "@cspell/url": "9.7.0", "ansi-regex": "^6.2.2", "chalk": "^5.6.2", "chalk-template": "^1.1.2", - "commander": "^14.0.2", - "cspell-config-lib": "9.4.0", - "cspell-dictionary": "9.4.0", - "cspell-gitignore": "9.4.0", - "cspell-glob": "9.4.0", - "cspell-io": "9.4.0", - "cspell-lib": "9.4.0", + "commander": "^14.0.3", + "cspell-config-lib": "9.7.0", + "cspell-dictionary": "9.7.0", + "cspell-gitignore": "9.7.0", + "cspell-glob": "9.7.0", + "cspell-io": "9.7.0", + "cspell-lib": "9.7.0", "fast-json-stable-stringify": "^2.1.0", "flatted": "^3.3.3", - "semver": "^7.7.3", + "semver": "^7.7.4", "tinyglobby": "^0.2.15" }, "bin": { @@ -4533,22 +4333,22 @@ "cspell-esm": "bin.mjs" }, "engines": { - "node": ">=20" + "node": ">=20.18" }, "funding": { "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" } }, "node_modules/cspell-config-lib": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.4.0.tgz", - "integrity": "sha512-CvQKSmK/DRIf3LpNx2sZth65pHW2AHngZqLkH3DTwnAPbiCAsE0XvCrVhvDfCNu/6uJIaa+NVHSs8GOf//DHBQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.7.0.tgz", + "integrity": "sha512-pguh8A3+bSJ1OOrKCiQan8bvaaY125de76OEFz7q1Pq309lIcDrkoL/W4aYbso/NjrXaIw6OjkgPMGRBI/IgGg==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/cspell-types": "9.4.0", - "comment-json": "^4.4.1", - "smol-toml": "^1.5.2", + "@cspell/cspell-types": "9.7.0", + "comment-json": "^4.5.1", + "smol-toml": "^1.6.0", "yaml": "^2.8.2" }, "engines": { @@ -4556,31 +4356,32 @@ } }, "node_modules/cspell-dictionary": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.4.0.tgz", - "integrity": "sha512-c2qscanRZChoHZFYI7KpvBMdy8i6wNwl2EflcNRrFiFOq67t9CgxLe54PafaqhrHGpBc8nElaZKciLvjj6Uscw==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.7.0.tgz", + "integrity": "sha512-k/Wz0so32+0QEqQe21V9m4BNXM5ZN6lz3Ix/jLCbMxFIPl6wT711ftjOWIEMFhvUOP0TWXsbzcuE9mKtS5mTig==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/cspell-pipe": "9.4.0", - "@cspell/cspell-types": "9.4.0", - "cspell-trie-lib": "9.4.0", - "fast-equals": "^5.3.3" + "@cspell/cspell-performance-monitor": "9.7.0", + "@cspell/cspell-pipe": "9.7.0", + "@cspell/cspell-types": "9.7.0", + "cspell-trie-lib": "9.7.0", + "fast-equals": "^6.0.0" }, "engines": { "node": ">=20" } }, "node_modules/cspell-gitignore": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.4.0.tgz", - "integrity": "sha512-HMrzLmJBUMSpaMMkltlTAz/aVOrHxixyhKfg5WbFCJ5JYZO6Qu3/JU3wRoOFoud9449wRjLkvrGmbbL2+vO6Lw==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.7.0.tgz", + "integrity": "sha512-MtoYuH4ah4K6RrmaF834npMcRsTKw0658mC6yvmBacUQOmwB/olqyuxF3fxtbb55HDb7cXDQ35t1XuwwGEQeZw==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/url": "9.4.0", - "cspell-glob": "9.4.0", - "cspell-io": "9.4.0" + "@cspell/url": "9.7.0", + "cspell-glob": "9.7.0", + "cspell-io": "9.7.0" }, "bin": { "cspell-gitignore": "bin.mjs" @@ -4590,13 +4391,13 @@ } }, "node_modules/cspell-glob": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.4.0.tgz", - "integrity": "sha512-Q87Suj9oXrhoKck15qWorCizBjMNxG/k3NjnhKIAMrF+PdUa1Mpl0MOD+hqV1Wvwh1UHcIMYCP3bR3XpBbNx+Q==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.7.0.tgz", + "integrity": "sha512-LUeAoEsoCJ+7E3TnUmWBscpVQOmdwBejMlFn0JkXy6LQzxrybxXBKf65RSdIv1o5QtrhQIMa358xXYQG0sv/tA==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/url": "9.4.0", + "@cspell/url": "9.7.0", "picomatch": "^4.0.3" }, "engines": { @@ -4604,14 +4405,14 @@ } }, "node_modules/cspell-grammar": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.4.0.tgz", - "integrity": "sha512-ie7OQ4Neflo+61bMzoLR7GtlZfMBAm2KL1U4iNqh15wUE5fDbvXeN15H5lu+gcO8BwYvC5wxZknw1x62/J8+3Q==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.7.0.tgz", + "integrity": "sha512-oEYME+7MJztfVY1C06aGcJgEYyqBS/v/ETkQGPzf/c6ObSAPRcUbVtsXZgnR72Gru9aBckc70xJcD6bELdoWCA==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/cspell-pipe": "9.4.0", - "@cspell/cspell-types": "9.4.0" + "@cspell/cspell-pipe": "9.7.0", + "@cspell/cspell-types": "9.7.0" }, "bin": { "cspell-grammar": "bin.mjs" @@ -4621,42 +4422,44 @@ } }, "node_modules/cspell-io": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.4.0.tgz", - "integrity": "sha512-8w30dqlO54H9w6WGlvZhHI5kytVbF3bYPqKJAZLWKEO36L2mdpf6/abx/FA4yVLJ56wmH1x0N0ZK32wNRl5C6A==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.7.0.tgz", + "integrity": "sha512-V7x0JHAUCcJPRCH8c0MQkkaKmZD2yotxVyrNEx2SZTpvnKrYscLEnUUTWnGJIIf9znzISqw116PLnYu2c+zd6Q==", "dev": true, "license": "MIT", "dependencies": { - "@cspell/cspell-service-bus": "9.4.0", - "@cspell/url": "9.4.0" + "@cspell/cspell-service-bus": "9.7.0", + "@cspell/url": "9.7.0" }, "engines": { "node": ">=20" } }, "node_modules/cspell-lib": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.4.0.tgz", - "integrity": "sha512-ajjioE59IEDNUPawfaBpiMfGC32iKPkuYd4T9ftguuef8VvyKRifniiUi1nxwGgAhzSfxHvWs7qdT+29Pp5TMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-bundled-dicts": "9.4.0", - "@cspell/cspell-pipe": "9.4.0", - "@cspell/cspell-resolver": "9.4.0", - "@cspell/cspell-types": "9.4.0", - "@cspell/dynamic-import": "9.4.0", - "@cspell/filetypes": "9.4.0", - "@cspell/strong-weak-map": "9.4.0", - "@cspell/url": "9.4.0", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.7.0.tgz", + "integrity": "sha512-aTx/aLRpnuY1RJnYAu+A8PXfm1oIUdvAQ4W9E66bTgp1LWI+2G2++UtaPxRIgI0olxE9vcXqUnKpjOpO+5W9bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "9.7.0", + "@cspell/cspell-performance-monitor": "9.7.0", + "@cspell/cspell-pipe": "9.7.0", + "@cspell/cspell-resolver": "9.7.0", + "@cspell/cspell-types": "9.7.0", + "@cspell/dynamic-import": "9.7.0", + "@cspell/filetypes": "9.7.0", + "@cspell/rpc": "9.7.0", + "@cspell/strong-weak-map": "9.7.0", + "@cspell/url": "9.7.0", "clear-module": "^4.1.2", - "cspell-config-lib": "9.4.0", - "cspell-dictionary": "9.4.0", - "cspell-glob": "9.4.0", - "cspell-grammar": "9.4.0", - "cspell-io": "9.4.0", - "cspell-trie-lib": "9.4.0", - "env-paths": "^3.0.0", + "cspell-config-lib": "9.7.0", + "cspell-dictionary": "9.7.0", + "cspell-glob": "9.7.0", + "cspell-grammar": "9.7.0", + "cspell-io": "9.7.0", + "cspell-trie-lib": "9.7.0", + "env-paths": "^4.0.0", "gensequence": "^8.0.8", "import-fresh": "^3.3.1", "resolve-from": "^5.0.0", @@ -4669,52 +4472,37 @@ } }, "node_modules/cspell-trie-lib": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.4.0.tgz", - "integrity": "sha512-bySJTm8XDiJAoC1MDo4lE/KpSNxydo13ZETC8TF7Hb3rbWI1c6o5eZ4+i/tkG3M94OvKV91+MeAvoMCe7GGgAw==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.7.0.tgz", + "integrity": "sha512-a2YqmcraL3g6I/4gY7SYWEZfP73oLluUtxO7wxompk/kOG2K1FUXyQfZXaaR7HxVv10axT1+NrjhOmXpfbI6LA==", "dev": true, "license": "MIT", - "dependencies": { - "@cspell/cspell-pipe": "9.4.0", - "@cspell/cspell-types": "9.4.0", - "gensequence": "^8.0.8" - }, "engines": { "node": ">=20" - } - }, - "node_modules/cspell/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "@cspell/cspell-types": "9.7.0" } }, "node_modules/css-functions-list": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", - "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12 || >=16" + "node": ">=12" } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -4733,21 +4521,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", - "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/culvert": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz", @@ -4764,77 +4537,17 @@ } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "license": "MIT", "engines": { - "node": "*" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/dayjs": { @@ -4868,9 +4581,9 @@ "license": "MIT" }, "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4940,6 +4653,7 @@ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", + "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -4957,6 +4671,7 @@ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", + "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -5015,6 +4730,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-4.0.1.tgz", @@ -5049,38 +4774,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dir-glob/node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5157,15 +4850,15 @@ "license": "MIT" }, "node_modules/electron": { - "version": "39.2.7", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz", - "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==", + "version": "41.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.0.tgz", + "integrity": "sha512-0XRFyxRqetmqtkkBvV++wGbHYJ7bD++f6EgJW8y9kX4pPRagwlmKDtzqXZhKiu0DIQppm3sXxzHWK9GYP91OKQ==", "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", + "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -5176,9 +4869,9 @@ } }, "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, @@ -5202,13 +4895,14 @@ } }, "node_modules/engine.io": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", - "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", @@ -5273,6 +4967,27 @@ "node": ">= 0.6" } }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enquirer": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", @@ -5298,13 +5013,16 @@ } }, "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", "dev": true, "license": "MIT", + "dependencies": { + "is-safe-filename": "^0.1.0" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5323,124 +5041,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/envsub": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/envsub/-/envsub-4.1.0.tgz", - "integrity": "sha512-B44hta3xNFu6+zDhOha1TIrZkQHGDO3G5K8D2sJIkm/s3XyQjxWBGp1B+b/Y74Go1PqMP+cp8moPR4JullnD9Q==", - "license": "ISC", - "dependencies": { - "bluebird": "^3.7.2", - "chalk": "^3.0.0", - "commander": "^4.0.1", - "diff": "^4.0.1", - "handlebars": "^4.5.3", - "lodash": "^4.17.15", - "replace-last": "^1.2.6", - "string.prototype.matchall": "^4.0.8" - }, - "bin": { - "envsub": "bin/envsub.js", - "envsubh": "bin/envsubh.js" - } - }, - "node_modules/envsub/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/envsub/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" } }, "node_modules/es-define-property": { @@ -5462,9 +5070,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -5480,38 +5088,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -5519,48 +5095,6 @@ "license": "MIT", "optional": true }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5611,32 +5145,29 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -5646,8 +5177,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5655,7 +5185,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -5670,13 +5200,13 @@ } }, "node_modules/eslint-fix-utils": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/eslint-fix-utils/-/eslint-fix-utils-0.4.0.tgz", - "integrity": "sha512-nCEciwqByGxsKiWqZjqK7xfL+7dUX9Pi0UL3J0tOwfxVN9e6Y59UxEt1ZYsc3XH0ce6T1WQM/QU2DbKK/6IG7g==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/eslint-fix-utils/-/eslint-fix-utils-0.4.2.tgz", + "integrity": "sha512-n7ZTcwwkP5scedlhvWMcqxED+O1NzXcj5Rxn/0kJQMP88k02vRcBfQ1qsk/JHb6Aw8bajFoetFCCBiNIcNCsvA==", "dev": true, "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "@types/estree": ">=1", @@ -5714,18 +5244,19 @@ } }, "node_modules/eslint-plugin-import-x": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.1.tgz", - "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "^8.35.0", + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", - "minimatch": "^9.0.3 || ^10.0.1", + "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" @@ -5737,8 +5268,8 @@ "url": "https://opencollective.com/eslint-plugin-import-x" }, "peerDependencies": { - "@typescript-eslint/utils": "^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "peerDependenciesMeta": { @@ -5750,179 +5281,131 @@ } } }, - "node_modules/eslint-plugin-import-x/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-jsdoc": { - "version": "61.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-61.5.0.tgz", - "integrity": "sha512-PR81eOGq4S7diVnV9xzFSBE4CDENRQGP0Lckkek8AdHtbj+6Bm0cItwlFnxsLFriJHspiE3mpu8U20eODyToIg==", + "version": "62.8.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.8.1.tgz", + "integrity": "sha512-e9358PdHgvcMF98foNd3L7hVCw70Lt+YcSL7JzlJebB8eT5oRJtW6bHMQKoAwJtw6q0q0w/fRIr2kwnHdFDI6A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.76.0", + "@es-joy/jsdoccomment": "~0.84.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", + "comment-parser": "1.4.5", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^10.4.0", - "esquery": "^1.6.0", + "espree": "^11.1.0", + "esquery": "^1.7.0", "html-entities": "^2.6.0", "object-deep-merge": "^2.0.0", "parse-imports-exports": "^0.2.4", - "semver": "^7.7.3", + "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "to-valid-identifier": "^1.0.0" }, "engines": { - "node": ">=20.11.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-package-json": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-0.85.0.tgz", - "integrity": "sha512-MrOxFvhbqLuk4FIPG9v3u9Amn0n137J8LKILHvgfxK3rRyAHEVzuZM0CtpXFTx7cx4LzmAzONtlpjbM0UFNuTA==", + "node_modules/eslint-plugin-jsdoc/node_modules/comment-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", + "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", "dev": true, "license": "MIT", - "dependencies": { - "@altano/repository-tools": "^2.0.1", - "change-case": "^5.4.4", - "detect-indent": "^7.0.2", - "detect-newline": "^4.0.1", - "eslint-fix-utils": "~0.4.0", - "package-json-validator": "~0.59.0", - "semver": "^7.7.3", - "sort-object-keys": "^2.0.0", - "sort-package-json": "^3.4.0", - "validate-npm-package-name": "^7.0.0" - }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "eslint": ">=8.0.0", - "jsonc-eslint-parser": "^2.0.0" + "node": ">= 12.0.0" } }, - "node_modules/eslint-plugin-playwright": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.4.0.tgz", - "integrity": "sha512-MWNXfXlLfwXAjj4Z80PvCCFCXgCYy5OCHan57Z/beGrjkJ3maG1GanuGX8Ck6T6fagplBx2ZdkifxSfByftaTQ==", + "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", - "dependencies": { - "globals": "^16.4.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=16.9.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, - "peerDependencies": { - "eslint": ">=8.40.0" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-plugin-playwright/node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "node_modules/eslint-plugin-jsdoc/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-plugin-vitest": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-vitest/-/eslint-plugin-vitest-0.5.4.tgz", - "integrity": "sha512-um+odCkccAHU53WdKAw39MY61+1x990uXjSPguUCq3VcEHdqJrOb8OTMrbYlY6f9jAKx7x98kLVlIe3RJeJqoQ==", + "node_modules/eslint-plugin-package-json": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-0.91.1.tgz", + "integrity": "sha512-rxmCAcuTvDqrtywsmVHcFxEZdJUTByetAelAZiAcjMqo0BS1090KbjTeyEOiEdpEqxwa83c+xHCjFoZX9OKEcA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^7.7.1" + "@altano/repository-tools": "^2.0.1", + "change-case": "^5.4.4", + "detect-indent": "^7.0.2", + "detect-newline": "^4.0.1", + "eslint-fix-utils": "~0.4.1", + "package-json-validator": "^1.3.1", + "semver": "^7.7.3", + "sort-object-keys": "^2.0.0", + "sort-package-json": "^3.4.0", + "validate-npm-package-name": "^7.0.0" }, "engines": { - "node": "^18.0.0 || >= 20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "vitest": "*" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "eslint": ">=8.0.0", + "jsonc-eslint-parser": ">=2.0.0" } }, - "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "node_modules/eslint-plugin-playwright": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.10.1.tgz", + "integrity": "sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" + "globals": "^17.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16.9.0" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": ">=8.40.0" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5932,6 +5415,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5941,9 +5425,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -5956,6 +5440,35 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5966,6 +5479,7 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", @@ -5993,9 +5507,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -6060,9 +5574,9 @@ "license": "MIT" }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, "license": "MIT" }, @@ -6173,9 +5687,9 @@ "license": "Apache-2.0" }, "node_modules/fast-equals": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", - "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz", + "integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==", "dev": true, "license": "MIT", "engines": { @@ -6395,9 +5909,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC" }, "node_modules/follow-redirects": { @@ -6420,21 +5934,6 @@ } } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6491,44 +5990,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/gensequence": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz", @@ -6550,9 +6011,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "dev": true, "license": "MIT", "engines": { @@ -6615,27 +6076,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6660,9 +6104,9 @@ } }, "node_modules/git-hooks-list": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.1.1.tgz", - "integrity": "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.2.1.tgz", + "integrity": "sha512-WNvqJjOxxs/8ZP9+DWdwWJ7cDsd60NHf39XnD82pDVrKO5q7xfPqpkK6hwEAmBa/ZSEE4IOoR75EzbbIuwGlMw==", "dev": true, "license": "MIT", "funding": { @@ -6712,16 +6156,16 @@ } }, "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", "dev": true, "license": "MIT", "dependencies": { - "ini": "4.1.1" + "ini": "6.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6776,9 +6220,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "license": "MIT", "engines": { "node": ">=18" @@ -6792,6 +6236,7 @@ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "license": "MIT", + "optional": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -6804,18 +6249,18 @@ } }, "node_modules/globby": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-15.0.0.tgz", - "integrity": "sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.1.1.tgz", + "integrity": "sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==", "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", - "path-type": "^6.0.0", + "is-path-inside": "^4.0.0", "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" + "unicorn-magic": "^0.4.0" }, "engines": { "node": ">=20" @@ -6886,37 +6331,14 @@ "license": "ISC", "optional": true }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, "node_modules/has-flag": { @@ -6933,6 +6355,7 @@ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", + "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -6940,41 +6363,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -6983,13 +6376,13 @@ } }, "node_modules/hashery": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.3.0.tgz", - "integrity": "sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.13.0" + "hookified": "^1.15.0" }, "engines": { "node": ">=20" @@ -7007,6 +6400,13 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -7017,9 +6417,9 @@ } }, "node_modules/hookified": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.14.0.tgz", - "integrity": "sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, "license": "MIT" }, @@ -7061,13 +6461,13 @@ "license": "MIT" }, "node_modules/html-tags": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", + "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20.10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7192,9 +6592,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7220,6 +6620,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -7236,6 +6637,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -7248,6 +6650,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -7280,27 +6683,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/ip-address": { @@ -7347,23 +6736,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -7371,40 +6743,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7417,34 +6755,6 @@ "node": ">=8" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -7460,39 +6770,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -7513,21 +6790,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -7544,25 +6806,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -7586,29 +6829,12 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" }, "node_modules/is-number": { "version": "7.0.0", @@ -7619,200 +6845,68 @@ "node": ">=0.12.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-promise": { + "node_modules/is-path-inside": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, "engines": { - "node": ">= 0.4" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7844,21 +6938,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -7886,9 +6965,9 @@ } }, "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -7904,10 +6983,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, "node_modules/jsdoc-type-pratt-parser": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-6.10.0.tgz", - "integrity": "sha512-+LexoTRyYui5iOhJGn13N9ZazL23nAHGkXsa1p/C8yeq79WRfLBag6ZZ0FQG2aRoc9yfo59JT9EYCQonOkHKkQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", + "integrity": "sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==", "dev": true, "license": "MIT", "engines": { @@ -7915,35 +7000,36 @@ } }, "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.28", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.6.0", - "cssstyle": "^5.3.4", - "data-urls": "^6.0.0", + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -7987,53 +7073,33 @@ "optional": true }, "node_modules/jsonc-eslint-parser": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.2.tgz", - "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-3.1.0.tgz", + "integrity": "sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "acorn": "^8.5.0", - "eslint-visitor-keys": "^3.0.0", - "espree": "^9.0.0", + "eslint-visitor-keys": "^5.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" } }, "node_modules/jsonc-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "peer": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/jsonc-eslint-parser/node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -8052,83 +7118,359 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "license": "MIT", "optional": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/katex": { - "version": "0.16.27", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", - "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" + "libc": [ + "musl" ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "katex": "cli.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/known-css-properties": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", - "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/leac": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", - "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, "funding": { - "url": "https://ko-fi.com/killymxi" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lines-and-columns": { @@ -8149,19 +7491,18 @@ } }, "node_modules/lint-staged": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", - "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.2", + "commander": "^14.0.3", "listr2": "^9.0.5", - "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", - "pidtree": "^0.6.0", + "picomatch": "^4.0.3", "string-argv": "^0.3.2", - "yaml": "^2.8.1" + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -8207,15 +7548,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.truncate": { @@ -8245,6 +7580,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -8256,9 +7608,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8276,14 +7628,14 @@ } }, "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, @@ -8304,9 +7656,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, "license": "MIT", "dependencies": { @@ -8346,19 +7698,21 @@ } }, "node_modules/markdownlint-cli2": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.20.0.tgz", - "integrity": "sha512-esPk+8Qvx/f0bzI7YelUeZp+jCtFOk3KjZ7s9iBQZ6HlymSXoTtWGiIRZP05/9Oy2ehIoIjenVwndxGtxOIJYQ==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.22.0.tgz", + "integrity": "sha512-mOC9BY/XGtdX3M9n3AgERd79F0+S7w18yBBTNIQ453sI87etZfp1z4eajqSMV70CYjbxKe5ktKvT2HCpvcWx9w==", "dev": true, "license": "MIT", "dependencies": { - "globby": "15.0.0", + "globby": "16.1.1", "js-yaml": "4.1.1", "jsonc-parser": "3.3.1", - "markdown-it": "14.1.0", + "jsonpointer": "5.0.1", + "markdown-it": "14.1.1", "markdownlint": "0.40.0", "markdownlint-cli2-formatter-default": "0.0.6", - "micromatch": "4.0.8" + "micromatch": "4.0.8", + "smol-toml": "1.6.0" }, "bin": { "markdownlint-cli2": "markdownlint-cli2-bin.mjs" @@ -8383,6 +7737,36 @@ "markdownlint-cli2": ">=0.0.4" } }, + "node_modules/markdownlint-cli2/node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/markdownlint/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -8406,9 +7790,9 @@ } }, "node_modules/mathml-tag-names": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", - "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", + "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", "dev": true, "license": "MIT", "funding": { @@ -8417,9 +7801,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, @@ -8440,13 +7824,13 @@ } }, "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9025,9 +8409,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9086,24 +8470,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", + "node": "18 || 20 || >=22" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mkdirp": { @@ -9134,9 +8512,9 @@ } }, "node_modules/moment-timezone": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.0.tgz", - "integrity": "sha512-ldA5lRNm3iJCWZcBCab4pnNL3HSZYXVb/3TYr75/1WCTWYuTqYUb5f/S384pncYjJ88lbO8Z4uPDvmoluHJc8Q==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.1.tgz", + "integrity": "sha512-1B9lmAhB9D9/sHaPC1N7wLFEVUoFldxOpOO96lOD1PvJ43vCd0ozDPbu0FEL3++VvawOlDkq8YD373tJmP5JHw==", "license": "MIT", "dependencies": { "moment": "^2.29.4" @@ -9161,23 +8539,73 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "license": "ISC" + "node_modules/msw": { + "version": "2.12.14", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz", + "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.17" + "node": ">=18" }, "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nanoid": { @@ -9268,12 +8696,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" - }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -9284,12 +8706,13 @@ } }, "node_modules/node-ical": { - "version": "0.22.1", - "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.22.1.tgz", - "integrity": "sha512-rMdc5hhJT3ZPtZIWfEXcetRrPl2xeHGpMJX8lq4VwyLKfOzGr+rXVtx0lD8lSf27Hn3mxrbU3MUcMSMXp4rIQA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.25.6.tgz", + "integrity": "sha512-Wo3GSBGYUNkcxOjHhevKTGa/+eB7ZwNLCq50h/eTX/YHSDZz1nT4KzXArO455CGHLD68gc5vw3nevqWQpOjjLQ==", "license": "Apache-2.0", "dependencies": { - "rrule": "2.8.1" + "rrule-temporal": "^1.4.7", + "temporal-polyfill": "^0.3.0" }, "engines": { "node": ">=18" @@ -9384,30 +8807,11 @@ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", + "optional": true, "engines": { "node": ">= 0.4" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -9469,27 +8873,17 @@ "type-check": "^0.4.0", "word-wrap": "^1.2.5" }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -9563,22 +8957,18 @@ } }, "node_modules/package-json-validator": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/package-json-validator/-/package-json-validator-0.59.0.tgz", - "integrity": "sha512-WBTDKtO9pBa9GmA1sPbQHqlWxRdnHNfLFIIA49PPgV7px/rG27gHX57DWy77qyu374fla4veaIHy+gA+qRRuug==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/package-json-validator/-/package-json-validator-1.3.1.tgz", + "integrity": "sha512-RfUMqyBoLa1qcPsKNAksnVDRuzDvLi//RqfMbf52RNMKsm+cWR/3Cfe6hvrTS/ATtEwvtm/57dPggXsau6++uA==", "dev": true, "license": "MIT", "dependencies": { "semver": "^7.7.2", "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^7.0.0", - "yargs": "~18.0.0" - }, - "bin": { - "pjv": "lib/bin/pjv.mjs" + "validate-npm-package-name": "^7.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/pako": { @@ -9729,27 +9119,11 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, "node_modules/pathe": { "version": "2.0.3", @@ -9782,9 +9156,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -9794,19 +9168,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/pidusage": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-3.0.2.tgz", @@ -9840,13 +9201,13 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -9859,9 +9220,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10041,19 +9402,10 @@ "node": ">=10" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -10079,13 +9431,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-resolve-nested-selector": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", - "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", - "dev": true, - "license": "MIT" - }, "node_modules/postcss-safe-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", @@ -10144,9 +9489,9 @@ } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -10160,9 +9505,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -10258,9 +9603,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "optional": true, "dependencies": { @@ -10288,22 +9633,29 @@ } }, "node_modules/qified": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.5.3.tgz", - "integrity": "sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", + "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.13.0" + "hookified": "^2.1.1" }, "engines": { "node": ">=20" } }, + "node_modules/qified/node_modules/hookified": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.1.tgz", + "integrity": "sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -10385,6 +9737,12 @@ "node": ">=0.8" } }, + "node_modules/read/node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -10398,9 +9756,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -10409,55 +9767,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/replace-last": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/replace-last/-/replace-last-1.2.6.tgz", - "integrity": "sha512-Cj+MK38VtNu1S5J73mEZY3ciQb9dJajNq1Q8inP4dn/MhJMjHwoAF3Z3FjspwAEV9pfABl565MQucmrjOkty4g==", - "license": "ISC", "engines": { - "node": ">= 4.0.0" + "node": ">=0.10.0" } }, "node_modules/require-from-string": { @@ -10573,18 +9890,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "MIT" }, "node_modules/reusify": { "version": "1.1.0", @@ -10622,46 +9933,38 @@ "node": ">=8.0" } }, - "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, "node_modules/router": { @@ -10680,20 +9983,24 @@ "node": ">= 18" } }, - "node_modules/rrule": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", - "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", - "license": "BSD-3-Clause", - "dependencies": { - "tslib": "^2.4.0" + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz", + "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/rrule/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "node_modules/rrule-temporal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.5.1.tgz", + "integrity": "sha512-mV82lZ7OzMOXX5g+SCR8FWRMSvhTnPSznjQ2Vguezjz1nEPEv/Ap/Z/FsIZ/AxA15M9X99m05911/MCXFY+FsA==", + "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + } }, "node_modules/run-parallel": { "version": "1.2.0", @@ -10739,25 +10046,6 @@ ], "license": "MIT" }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -10765,39 +10053,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10805,10 +10060,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", - "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", - "license": "BlueOak-1.0.0" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/saxes": { "version": "6.0.0", @@ -10836,9 +10094,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, "license": "ISC", "bin": { @@ -10897,6 +10155,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -10916,52 +10187,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -11075,10 +10300,17 @@ "license": "ISC" }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/sirv": { "version": "3.0.2", @@ -11109,35 +10341,22 @@ } }, "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -11149,9 +10368,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11184,15 +10403,36 @@ "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.18.3" + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -11274,16 +10514,16 @@ } }, "node_modules/sort-object-keys": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.0.1.tgz", - "integrity": "sha512-R89fO+z3x7hiKPXX5P0qim+ge6Y60AjtlW+QQpRozrrNcR1lw9Pkpm5MLB56HoNvdcLHL4wbpq16OcvGpEDJIg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.1.0.tgz", + "integrity": "sha512-SOiEnthkJKPv2L6ec6HMwhUcN0/lppkeYuN1x63PbyPRrgSPIuBJCiYxYyvWRTtjMlOi14vQUCGUJqS6PLVm8g==", "dev": true, "license": "MIT" }, "node_modules/sort-package-json": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.6.0.tgz", - "integrity": "sha512-fyJsPLhWvY7u2KsKPZn1PixbXp+1m7V8NWqU8CvgFRbMEX41Ffw1kD8n0CfJiGoaSfoAvbrqRRl/DcHO8omQOQ==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.6.1.tgz", + "integrity": "sha512-Chgejw1+10p2D0U2tB7au1lHtz6TkFnxmvZktyBCRyV0GgmF6nl1IxXxAsPtJVsUyg/fo+BfCMAVVFUVRkAHrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11372,9 +10612,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -11411,24 +10651,18 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" }, "node_modules/string-argv": { "version": "0.3.2", @@ -11441,14 +10675,14 @@ } }, "node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -11457,97 +10691,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -11556,22 +10707,10 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stylelint": { - "version": "16.26.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz", - "integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.6.0.tgz", + "integrity": "sha512-tokrsMIVAR9vAQ/q3UVEr7S0dGXCi7zkCezPRnS2kqPUulvUh5Vgfwngrk4EoAoW7wnrThqTdnTFN5Ra7CaxIg==", "dev": true, "funding": [ { @@ -11585,57 +10724,54 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-syntax-patches-for-csstree": "^1.0.19", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3", - "@csstools/selector-specificity": "^5.0.0", - "@dual-bundle/import-meta-resolve": "^4.2.1", - "balanced-match": "^2.0.0", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0", + "@csstools/selector-resolve-nested": "^4.0.0", + "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", - "cosmiconfig": "^9.0.0", - "css-functions-list": "^3.2.3", - "css-tree": "^3.1.0", + "cosmiconfig": "^9.0.1", + "css-functions-list": "^3.3.3", + "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.1", + "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", - "globby": "^11.1.0", + "globby": "^16.1.1", "globjoin": "^0.1.4", - "html-tags": "^3.3.1", + "html-tags": "^5.1.0", "ignore": "^7.0.5", - "imurmurhash": "^0.1.4", + "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", - "known-css-properties": "^0.37.0", - "mathml-tag-names": "^2.1.3", - "meow": "^13.2.0", + "mathml-tag-names": "^4.0.0", + "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.6", - "postcss-resolve-nested-selector": "^0.1.6", + "postcss": "^8.5.8", "postcss-safe-parser": "^7.0.1", - "postcss-selector-parser": "^7.1.0", + "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", - "resolve-from": "^5.0.0", - "string-width": "^4.2.3", - "supports-hyperlinks": "^3.2.0", + "string-width": "^8.2.0", + "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", - "write-file-atomic": "^5.0.1" + "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" } }, "node_modules/stylelint-config-recommended": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-17.0.0.tgz", - "integrity": "sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", + "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", "dev": true, "funding": [ { @@ -11649,16 +10785,16 @@ ], "license": "MIT", "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" }, "peerDependencies": { - "stylelint": "^16.23.0" + "stylelint": "^17.0.0" } }, "node_modules/stylelint-config-standard": { - "version": "39.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-39.0.1.tgz", - "integrity": "sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==", + "version": "40.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz", + "integrity": "sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==", "dev": true, "funding": [ { @@ -11672,13 +10808,13 @@ ], "license": "MIT", "dependencies": { - "stylelint-config-recommended": "^17.0.0" + "stylelint-config-recommended": "^18.0.0" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" }, "peerDependencies": { - "stylelint": "^16.23.0" + "stylelint": "^17.0.0" } }, "node_modules/stylelint-prettier": { @@ -11698,81 +10834,26 @@ "stylelint": ">=16.0.0" } }, - "node_modules/stylelint/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/stylelint/node_modules/balanced-match": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", - "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", - "dev": true, - "license": "MIT" - }, - "node_modules/stylelint/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.1.tgz", - "integrity": "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz", + "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.19" + "flat-cache": "^6.1.20" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.19", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.19.tgz", - "integrity": "sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==", + "version": "6.1.22", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz", + "integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==", "dev": true, "license": "MIT", "dependencies": { - "cacheable": "^2.2.0", - "flatted": "^3.3.3", - "hookified": "^1.13.0" - } - }, - "node_modules/stylelint/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stylelint/node_modules/globby/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "cacheable": "^2.3.4", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/stylelint/node_modules/ignore": { @@ -11785,54 +10866,6 @@ "node": ">= 4" } }, - "node_modules/stylelint/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/stylelint/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/stylelint/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stylelint/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -11864,22 +10897,48 @@ } }, "node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", + "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" }, "engines": { - "node": ">=14.18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -11906,9 +10965,9 @@ "license": "MIT" }, "node_modules/systeminformation": { - "version": "5.28.2", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.28.2.tgz", - "integrity": "sha512-AWrPnmbIcfjLX7wiQMd7r6bl3PDaGoqvrfn8pCLJbk9CiB+vQXTk8RVxNs2vQRTezYlhb9skxEIyAPTm1If5bQ==", + "version": "5.31.5", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.5.tgz", + "integrity": "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==", "license": "MIT", "os": [ "darwin", @@ -11958,12 +11017,21 @@ "node": ">=8" } }, - "node_modules/table/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/table/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, "node_modules/table/node_modules/is-fullwidth-code-point": { "version": "3.0.0", @@ -12021,6 +11089,34 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "license": "ISC" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -12029,9 +11125,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -12056,9 +11152,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -12066,22 +11162,22 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", "dev": true, "license": "MIT" }, @@ -12134,9 +11230,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12160,16 +11256,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/tslib": { @@ -12219,13 +11315,16 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, "license": "(MIT OR CC0-1.0)", - "optional": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12245,84 +11344,10 @@ "node": ">= 0.6" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -12341,60 +11366,29 @@ "dev": true, "license": "MIT" }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12454,6 +11448,16 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -12493,9 +11497,9 @@ } }, "node_modules/validate-npm-package-name": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.1.tgz", - "integrity": "sha512-BM0Upcemlce8/9+HE+/VpWqn3u3mYh6Om/FEC8yPMnEHwf710fW5Q6fhjT1SQyRlZD1G9CJbgfH+rWgAcIvjlQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", "dev": true, "license": "ISC", "engines": { @@ -12512,17 +11516,16 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "bin": { @@ -12539,9 +11542,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -12554,13 +11558,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -12602,31 +11609,31 @@ } }, "node_modules/vitest": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", - "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.16", - "@vitest/mocker": "4.0.16", - "@vitest/pretty-format": "4.0.16", - "@vitest/runner": "4.0.16", - "@vitest/snapshot": "4.0.16", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -12642,12 +11649,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.16", - "@vitest/browser-preview": "4.0.16", - "@vitest/browser-webdriverio": "4.0.16", - "@vitest/ui": "4.0.16", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -12676,6 +11684,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -12743,9 +11754,9 @@ "license": "MIT" }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -12753,27 +11764,28 @@ } }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", "dependencies": { + "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/which": { @@ -12791,91 +11803,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -12902,12 +11829,6 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "license": "MIT" - }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -12926,18 +11847,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", @@ -12964,43 +11879,29 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", "dev": true, "license": "ISC", "dependencies": { - "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=8.3.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "utf-8-validate": "^5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -13058,9 +11959,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -13074,49 +11975,80 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^9.0.1", + "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" + "yargs-parser": "^21.1.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=12" } }, "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=18" + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, "node_modules/yauzl": { @@ -13141,6 +12073,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index cfc0cd6c12..5cad85d4a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "magicmirror", - "version": "2.34.0", + "version": "2.35.0", "description": "The open source modular smart mirror platform.", "keywords": [ "magic mirror", @@ -27,28 +27,40 @@ ], "type": "commonjs", "imports": { - "#module_functions": { - "default": "./js/module_functions.js" - }, "#server_functions": { "default": "./js/server_functions.js" + }, + "#http_fetcher": { + "default": "./js/http_fetcher.js" } }, "main": "js/electron.js", + "exports": "./js/electron.js", + "files": [ + "clientonly", + "css", + "defaultmodules", + "js", + "serveronly", + "translations", + "favicon.svg", + "index.html" + ], + "sideEffects": true, "scripts": { "config:check": "node js/check_config.js", - "postinstall": "git clean -df fonts vendor", + "postinstall": "git clean -df fonts vendor modules/default", "install-mm": "npm install --no-audit --no-fund --no-update-notifier --only=prod --omit=dev", "install-mm:dev": "npm install --no-audit --no-fund --no-update-notifier && npx playwright install chromium", - "lint:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css' --fix", + "lint:css": "stylelint 'css/**/*.css' 'defaultmodules/**/*.css' --fix", "lint:js": "eslint --fix", "lint:markdown": "markdownlint-cli2 . --fix", "lint:prettier": "prettier . --write", "prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.", "server": "node ./serveronly", "server:watch": "node ./serveronly/watcher.js", - "start": "node --run start:x11", - "start:dev": "node --run start:x11 -- dev", + "start": "node --run start:wayland", + "start:dev": "node --run start:wayland -- dev", "start:wayland": "WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY:=wayland-1}\" ./node_modules/.bin/electron js/electron.js --ozone-platform=wayland", "start:wayland:dev": "node --run start:wayland -- dev", "start:windows": ".\\node_modules\\.bin\\electron js\\electron.js", @@ -56,9 +68,9 @@ "start:x11": "DISPLAY=\"${DISPLAY:=:0}\" ./node_modules/.bin/electron js/electron.js", "start:x11:dev": "node --run start:x11 -- dev", "test": "vitest run", - "test:calendar": "node ./modules/default/calendar/debug.js", + "test:calendar": "node ./defaultmodules/calendar/debug.js", "test:coverage": "vitest run --coverage", - "test:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css'", + "test:css": "stylelint 'css/**/*.css' 'defaultmodules/**/*.css'", "test:e2e": "vitest run tests/e2e", "test:electron": "vitest run tests/electron", "test:js": "eslint", @@ -75,57 +87,58 @@ "*.css": "stylelint --fix" }, "dependencies": { - "@fontsource/roboto": "^5.2.9", + "@fontsource/roboto": "^5.2.10", "@fontsource/roboto-condensed": "^5.2.8", - "@fortawesome/fontawesome-free": "^7.1.0", - "ajv": "^8.17.1", + "@fortawesome/fontawesome-free": "^7.2.0", + "ajv": "^8.18.0", "animate.css": "^4.1.1", - "console-stamp": "^3.1.2", - "croner": "^9.1.0", - "envsub": "^4.1.0", - "eslint": "^9.39.2", + "croner": "^10.0.1", + "eslint": "^10.1.0", "express": "^5.2.1", "feedme": "^2.0.2", + "globals": "^17.4.0", "helmet": "^8.1.0", "html-to-text": "^9.0.5", - "iconv-lite": "^0.7.1", + "iconv-lite": "^0.7.2", "ipaddr.js": "^2.3.0", "moment": "^2.30.1", - "moment-timezone": "^0.6.0", - "node-ical": "^0.22.1", + "moment-timezone": "^0.6.1", + "node-ical": "^0.25.6", "nunjucks": "^3.2.4", "pm2": "^6.0.14", "socket.io": "^4.8.3", "suncalc": "^1.9.0", - "systeminformation": "^5.28.2", - "undici": "^7.16.0", + "systeminformation": "^5.31.5", + "undici": "^7.24.6", "weathericons": "^2.1.0" }, "devDependencies": { - "@stylistic/eslint-plugin": "^5.6.1", - "@vitest/coverage-v8": "^4.0.16", - "@vitest/ui": "^4.0.16", - "cspell": "^9.4.0", - "eslint-plugin-import-x": "^4.16.1", - "eslint-plugin-jsdoc": "^61.5.0", - "eslint-plugin-package-json": "^0.85.0", - "eslint-plugin-playwright": "^2.4.0", - "eslint-plugin-vitest": "^0.5.4", + "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", + "@vitest/coverage-v8": "^4.1.2", + "@vitest/eslint-plugin": "^1.6.14", + "@vitest/ui": "^4.1.2", + "cspell": "^9.7.0", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-jsdoc": "^62.8.1", + "eslint-plugin-package-json": "^0.91.1", + "eslint-plugin-playwright": "^2.10.1", "express-basic-auth": "^1.2.1", "husky": "^9.1.7", - "jsdom": "^27.4.0", - "lint-staged": "^16.2.7", - "markdownlint-cli2": "^0.20.0", - "playwright": "^1.57.0", - "prettier": "^3.7.4", + "jsdom": "^29.0.1", + "lint-staged": "^16.4.0", + "markdownlint-cli2": "^0.22.0", + "msw": "^2.12.14", + "playwright": "^1.58.2", + "prettier": "^3.8.1", "prettier-plugin-jinja-template": "^2.1.0", - "stylelint": "^16.26.1", - "stylelint-config-standard": "^39.0.1", + "stylelint": "^17.6.0", + "stylelint-config-standard": "^40.0.0", "stylelint-prettier": "^5.0.3", - "vitest": "^4.0.16" + "vitest": "^4.1.2" }, "optionalDependencies": { - "electron": "^39.2.7" + "electron": "^41.1.0" }, "engines": { "node": ">=22.21.1 <23 || >=24" diff --git a/serveronly/watcher.js b/serveronly/watcher.js index 0f75a5fb49..c73be29415 100644 --- a/serveronly/watcher.js +++ b/serveronly/watcher.js @@ -1,11 +1,11 @@ // Load lightweight internal alias resolver to enable require("logger") require("../js/alias-resolver"); -const { spawn } = require("child_process"); -const fs = require("fs"); -const path = require("path"); -const net = require("net"); -const http = require("http"); +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const net = require("node:net"); +const http = require("node:http"); const Log = require("logger"); const { getConfigFilePath } = require("#server_functions"); @@ -145,10 +145,10 @@ function notifyClientsToReload () { * Restart the server process * @param {string} reason The reason for the restart */ -async function restartServer (reason) { +function restartServer (reason) { if (restartTimer) clearTimeout(restartTimer); - restartTimer = setTimeout(async () => { + restartTimer = setTimeout(() => { Log.info(reason); if (child) { diff --git a/tests/configs/config_variables.env b/tests/configs/config_variables.env new file mode 100644 index 0000000000..6f38944695 --- /dev/null +++ b/tests/configs/config_variables.env @@ -0,0 +1,7 @@ +MM_LANGUAGE=de +MM_TIME_FORMAT=12 +MM_LOG_INFO=INFO +MM_LOG_ERROR=ERROR +SECRET_IP1="127.0.0.1" +SECRET_IP2="::ffff:127.0.0.1" +SECRET_IP3=1 diff --git a/tests/configs/config_variables.js b/tests/configs/config_variables.js new file mode 100644 index 0000000000..2068a22a6f --- /dev/null +++ b/tests/configs/config_variables.js @@ -0,0 +1,12 @@ +let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ + language: "${MM_LANGUAGE}", + logLevel: ["${MM_LOG_ERROR}", "LOG", "WARN", "${MM_LOG_INFO}"], + timeFormat: ${MM_TIME_FORMAT}, + hideConfigSecrets: true, + ipWhitelist: ["${SECRET_IP2}", "::${SECRET_IP3}", "${SECRET_IP1}"] +}); + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/calendarShowEndConfigs.js b/tests/configs/modules/calendar/calendarShowEndConfigs.js new file mode 100644 index 0000000000..129ad97388 --- /dev/null +++ b/tests/configs/modules/calendar/calendarShowEndConfigs.js @@ -0,0 +1,361 @@ +const calendarShowEndConfigs = { + event_with_time_over_multiple_days_non_repeating_display_end: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics" + } + ] + } + } + ] + }, + event_with_time_over_multiple_days_non_repeating_display_end_dateheaders: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_over_multiple_days_non_repeating_display_end_relative: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_over_multiple_days_non_repeating_no_display_end: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + showEndsOnlyWithDuration: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_absolute: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_absolute_dateformat_lll: { + address: "0.0.0.0", + ipWhitelist: [], + language: "en", + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "LLL", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_absolute_dateformat_with_time: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_dateheaders: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_relative: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_relative_hide_time: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + hideTime: true, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + fullday_multiday_showend_dateheaders: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] + }, + fullday_multiday_showend_nextdaysrelative: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + nextDaysRelative: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] + }, + fullday_multiday_showend_relative: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] + } +}; + +const defaultScenario = "event_with_time_over_multiple_days_non_repeating_display_end"; +const selectedScenario = process.env.MM_CALENDAR_SHOWEND_SCENARIO || defaultScenario; +const config = calendarShowEndConfigs[selectedScenario]; + +if (!config) { + throw new Error(`Unknown MM_CALENDAR_SHOWEND_SCENARIO: ${selectedScenario}`); +} + +module.exports = config; diff --git a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js deleted file mode 100644 index 95989648ca..0000000000 --- a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js +++ /dev/null @@ -1,33 +0,0 @@ -let config = { - address: "0.0.0.0", - ipWhitelist: [], - - timeFormat: 24, - modules: [ - { - module: "calendar", - position: "bottom_bar", - config: { - fade: false, - urgency: 0, - dateFormat: "Do.MMM, HH:mm", - dateEndFormat: "Do.MMM, HH:mm", - fullDayEventDateFormat: "Do.MMM", - timeFormat: "absolute", - getRelative: 0, - showEnd: true, - calendars: [ - { - maximumEntries: 100, - url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics" - } - ] - } - } - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") { - module.exports = config; -} diff --git a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js b/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js deleted file mode 100644 index ef60df4c94..0000000000 --- a/tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js +++ /dev/null @@ -1,34 +0,0 @@ -let config = { - address: "0.0.0.0", - ipWhitelist: [], - - timeFormat: 24, - modules: [ - { - module: "calendar", - position: "bottom_bar", - config: { - fade: false, - urgency: 0, - dateFormat: "Do.MMM, HH:mm", - dateEndFormat: "Do.MMM, HH:mm", - fullDayEventDateFormat: "Do.MMM", - timeFormat: "absolute", - getRelative: 0, - showEnd: true, - showEndsOnlyWithDuration: true, - calendars: [ - { - maximumEntries: 100, - url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics" - } - ] - } - } - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") { - module.exports = config; -} diff --git a/tests/configs/modules/calendar/symboltest.js b/tests/configs/modules/calendar/symboltest.js index 4fc4391771..060bc82575 100644 --- a/tests/configs/modules/calendar/symboltest.js +++ b/tests/configs/modules/calendar/symboltest.js @@ -1,4 +1,3 @@ - let config = { address: "0.0.0.0", ipWhitelist: [], diff --git a/tests/configs/modules/newsfeed/notifications.js b/tests/configs/modules/newsfeed/notifications.js new file mode 100644 index 0000000000..43bd0b1db1 --- /dev/null +++ b/tests/configs/modules/newsfeed/notifications.js @@ -0,0 +1,26 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "newsfeed", + position: "bottom_bar", + config: { + feeds: [ + { + title: "Rodrigo Ramirez Blog", + url: "http://localhost:8080/tests/mocks/newsfeed_test.xml" + } + ], + updateInterval: 3600 * 1000 // 1 hour - prevent auto-rotation during tests + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/currentweather_compliments.js b/tests/configs/modules/weather/currentweather_compliments.js index 603fafa173..70bb1b8f01 100644 --- a/tests/configs/modules/weather/currentweather_compliments.js +++ b/tests/configs/modules/weather/currentweather_compliments.js @@ -16,10 +16,10 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/currentweather_default.js b/tests/configs/modules/weather/currentweather_default.js index e5a9fdce4e..0e6e9f1752 100644 --- a/tests/configs/modules/weather/currentweather_default.js +++ b/tests/configs/modules/weather/currentweather_default.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, showHumidity: "feelslike", weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/currentweather_options.js b/tests/configs/modules/weather/currentweather_options.js index 0ddb8b7cf2..814fca5520 100644 --- a/tests/configs/modules/weather/currentweather_options.js +++ b/tests/configs/modules/weather/currentweather_options.js @@ -6,10 +6,10 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", windUnits: "beaufort", showWindDirectionAsArrow: true, showSun: false, diff --git a/tests/configs/modules/weather/currentweather_units.js b/tests/configs/modules/weather/currentweather_units.js index 462b67f682..33baecd6b1 100644 --- a/tests/configs/modules/weather/currentweather_units.js +++ b/tests/configs/modules/weather/currentweather_units.js @@ -8,10 +8,10 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", decimalSymbol: ",", showHumidity: "wind" } diff --git a/tests/configs/modules/weather/forecastweather_absolute.js b/tests/configs/modules/weather/forecastweather_absolute.js index ff18bdf973..01fc4f43a9 100644 --- a/tests/configs/modules/weather/forecastweather_absolute.js +++ b/tests/configs/modules/weather/forecastweather_absolute.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", absoluteDates: true } } diff --git a/tests/configs/modules/weather/forecastweather_default.js b/tests/configs/modules/weather/forecastweather_default.js index a53ba1273b..4cb23763bc 100644 --- a/tests/configs/modules/weather/forecastweather_default.js +++ b/tests/configs/modules/weather/forecastweather_default.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/forecastweather_options.js b/tests/configs/modules/weather/forecastweather_options.js index 0e80198814..25ff5bcde0 100644 --- a/tests/configs/modules/weather/forecastweather_options.js +++ b/tests/configs/modules/weather/forecastweather_options.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", showPrecipitationAmount: true, colored: true, tableClass: "myTableClass" diff --git a/tests/configs/modules/weather/forecastweather_units.js b/tests/configs/modules/weather/forecastweather_units.js index 73bcde9727..a71afaad56 100644 --- a/tests/configs/modules/weather/forecastweather_units.js +++ b/tests/configs/modules/weather/forecastweather_units.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", decimalSymbol: "_", showPrecipitationAmount: true } diff --git a/tests/configs/modules/weather/hourlyweather_default.js b/tests/configs/modules/weather/hourlyweather_default.js index 191ceab1d2..e7437b0920 100644 --- a/tests/configs/modules/weather/hourlyweather_default.js +++ b/tests/configs/modules/weather/hourlyweather_default.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { + lat: 48.14, + lon: 11.58, type: "hourly", - location: "Berlin", weatherProvider: "openweathermap", - weatherEndpoint: "/onecall", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/hourlyweather_options.js b/tests/configs/modules/weather/hourlyweather_options.js index c11d23dbd3..0e323a9f7f 100644 --- a/tests/configs/modules/weather/hourlyweather_options.js +++ b/tests/configs/modules/weather/hourlyweather_options.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { + lat: 48.14, + lon: 11.58, type: "hourly", - location: "Berlin", weatherProvider: "openweathermap", - weatherEndpoint: "/onecall", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", hourlyForecastIncrements: 2 } } diff --git a/tests/configs/modules/weather/hourlyweather_showPrecipitation.js b/tests/configs/modules/weather/hourlyweather_showPrecipitation.js index 3dbbc41837..bc04a9917d 100644 --- a/tests/configs/modules/weather/hourlyweather_showPrecipitation.js +++ b/tests/configs/modules/weather/hourlyweather_showPrecipitation.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { + lat: 48.14, + lon: 11.58, type: "hourly", - location: "Berlin", weatherProvider: "openweathermap", - weatherEndpoint: "/onecall", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", showPrecipitationAmount: true, showPrecipitationProbability: true } diff --git a/tests/configs/port_variable.env b/tests/configs/port_variable.env deleted file mode 100644 index 2b19af0113..0000000000 --- a/tests/configs/port_variable.env +++ /dev/null @@ -1 +0,0 @@ -MM_PORT=8090 diff --git a/tests/configs/port_variable.js.template b/tests/configs/port_variable.js.template deleted file mode 100644 index e44aa5ac90..0000000000 --- a/tests/configs/port_variable.js.template +++ /dev/null @@ -1,8 +0,0 @@ -let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ - port: ${MM_PORT} -}); - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") { - module.exports = config; -} diff --git a/tests/e2e/clientonly_spec.js b/tests/e2e/clientonly_spec.js new file mode 100644 index 0000000000..e19d4eee46 --- /dev/null +++ b/tests/e2e/clientonly_spec.js @@ -0,0 +1,179 @@ +const { spawnSync, spawn } = require("node:child_process"); + +const delay = (time) => { + return new Promise((resolve) => setTimeout(resolve, time)); +}; + +/** + * Run clientonly with given arguments and return result + * @param {string[]} args command line arguments + * @param {object} env environment variables to merge (replaces process.env) + * @returns {object} result with status and stderr + */ +const runClientOnly = (args = [], env = {}) => { + // Start with minimal env and merge provided env + const testEnv = { + PATH: process.env.PATH, + NODE_PATH: process.env.NODE_PATH, + ...env + }; + const result = spawnSync("node", ["clientonly/index.js", ...args], { + env: testEnv, + encoding: "utf-8", + timeout: 5000 + }); + return result; +}; + +describe("Clientonly parameter handling", () => { + + describe("Missing parameters", () => { + it("should fail without any parameters", () => { + const result = runClientOnly(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with only address parameter", () => { + const result = runClientOnly(["--address", "192.168.1.10"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with only port parameter", () => { + const result = runClientOnly(["--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + }); + + describe("Local address rejection", () => { + it("should fail with localhost address", () => { + const result = runClientOnly(["--address", "localhost", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with 127.0.0.1 address", () => { + const result = runClientOnly(["--address", "127.0.0.1", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with ::1 address", () => { + const result = runClientOnly(["--address", "::1", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with ::ffff:127.0.0.1 address", () => { + const result = runClientOnly(["--address", "::ffff:127.0.0.1", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + }); + + describe("Port validation", () => { + it("should fail with port 0", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "0"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should fail with negative port", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "-1"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should fail with port above 65535", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "65536"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should fail with non-numeric port", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "abc"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should accept valid port 8080", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "8080"]); + // Should not fail on port validation (will fail on connection or display) + expect(result.stderr).not.toContain("Invalid port number"); + }); + + it("should accept valid port 1", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "1"]); + expect(result.stderr).not.toContain("Invalid port number"); + }); + + it("should accept valid port 65535", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "65535"]); + expect(result.stderr).not.toContain("Invalid port number"); + }); + }); + + describe("TLS flag parsing", () => { + // Note: These tests verify the flag is parsed, not the actual connection behavior + // Connection tests would timeout as they try to reach unreachable addresses + + it("should not fail on port validation when using --use-tls", () => { + // Verify --use-tls doesn't interfere with other parameter parsing + const result = runClientOnly(["--address", "192.168.1.10", "--port", "443", "--use-tls"]); + expect(result.stderr).not.toContain("Invalid port number"); + }); + + it("should accept --use-tls flag with valid parameters", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "443", "--use-tls"]); + // Should not fail on parameter parsing (will fail on connection or display) + expect(result.stderr).not.toContain("Usage:"); + }); + }); + + describe("Display environment check", () => { + it("should fail without DISPLAY or WAYLAND_DISPLAY when connecting to valid server", () => { + // This test needs a running server to get past the connection phase + // Without DISPLAY, it should fail with display error + // For now, we just verify it fails (connection error comes first without server) + const result = runClientOnly(["--address", "192.168.1.10", "--port", "1"]); + // Either exits with code 1 or times out (null status means killed/timeout) + expect(result.status === 1 || result.status === null).toBe(true); + }); + }); +}); + +describe("Clientonly with running server", () => { + let serverProcess; + const testPort = 8081; + + beforeAll(async () => { + process.env.MM_CONFIG_FILE = "tests/configs/default.js"; + process.env.MM_PORT = testPort.toString(); + serverProcess = spawn("node", ["--run", "server"], { + env: process.env, + detached: true + }); + // Wait for server to start + await delay(2000); + }); + + afterAll(() => { + if (serverProcess && serverProcess.pid) { + try { + process.kill(-serverProcess.pid); + } catch { + // Process may already be dead + } + } + }); + + it("should be able to fetch config from server", async () => { + const res = await fetch(`http://localhost:${testPort}/config/`); + expect(res.status).toBe(200); + const config = await res.json(); + expect(config).toBeDefined(); + expect(typeof config).toBe("object"); + }); +}); diff --git a/tests/e2e/config_variables_spec.js b/tests/e2e/config_variables_spec.js new file mode 100644 index 0000000000..4fb9cedd17 --- /dev/null +++ b/tests/e2e/config_variables_spec.js @@ -0,0 +1,34 @@ +const helpers = require("./helpers/global-setup"); + +describe("config with variables and secrets", () => { + beforeAll(async () => { + await helpers.startApplication("tests/configs/config_variables.js"); + }); + + afterAll(async () => { + await helpers.stopApplication(); + }); + + it("config.language should be \"de\"", () => { + expect(config.language).toBe("de"); + }); + + it("config.loglevel should be [\"ERROR\", \"LOG\", \"WARN\", \"INFO\"]", () => { + expect(config.logLevel).toStrictEqual(["ERROR", "LOG", "WARN", "INFO"]); + }); + + it("config.ipWhitelist should be [\"::ffff:127.0.0.1\", \"::1\", \"127.0.0.1\"]", () => { + expect(config.ipWhitelist).toStrictEqual(["::ffff:127.0.0.1", "::1", "127.0.0.1"]); + }); + + it("config.timeFormat should be 12", () => { + expect(config.timeFormat).toBe(12); // default is 24 + }); + + it("/config endpoint should show redacted secrets", async () => { + const res = await fetch(`http://localhost:${config.port}/config`); + expect(res.status).toBe(200); + const cfg = await res.json(); + expect(cfg.ipWhitelist).toStrictEqual(["**SECRET_IP2**", "::**SECRET_IP3**", "**SECRET_IP1**"]); + }); +}); diff --git a/tests/e2e/custom_module_regions_spec.js b/tests/e2e/custom_module_regions_spec.js index 243f8e489a..d52e2cdc1c 100644 --- a/tests/e2e/custom_module_regions_spec.js +++ b/tests/e2e/custom_module_regions_spec.js @@ -10,6 +10,7 @@ describe("Custom Position of modules", () => { await helpers.getDocument(); page = helpers.getPage(); }); + afterAll(async () => { await helpers.stopApplication(); await helpers.restoreIndex(); diff --git a/tests/e2e/env_spec.js b/tests/e2e/env_spec.js index 5e642ed476..14383e24dd 100644 --- a/tests/e2e/env_spec.js +++ b/tests/e2e/env_spec.js @@ -9,6 +9,7 @@ describe("App environment", () => { await helpers.getDocument(); page = helpers.getPage(); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/e2e/fonts_spec.js b/tests/e2e/fonts_spec.js index 07ed1a5f64..524a4e098a 100644 --- a/tests/e2e/fonts_spec.js +++ b/tests/e2e/fonts_spec.js @@ -16,6 +16,7 @@ describe("All font files from roboto.css should be downloadable", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/without_modules.js"); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/e2e/helpers/global-setup.js b/tests/e2e/helpers/global-setup.js index baa8bc1596..8bbb810b7b 100644 --- a/tests/e2e/helpers/global-setup.js +++ b/tests/e2e/helpers/global-setup.js @@ -7,7 +7,7 @@ const { chromium } = require("playwright"); global.root_path = path.resolve(`${__dirname}/../../../`); const indexFile = `${global.root_path}/index.html`; -const cssFile = `${global.root_path}/css/custom.css`; +const cssFile = `${global.root_path}/config/custom.css`; const sampleCss = [ ".region.row3 {", " top: 0;", @@ -88,7 +88,6 @@ exports.getPage = () => { return page; }; - exports.startApplication = async (configFilename, exec) => { vi.resetModules(); diff --git a/tests/e2e/helpers/weather-functions.js b/tests/e2e/helpers/weather-functions.js index 6780ea42c1..8eb0c0699e 100644 --- a/tests/e2e/helpers/weather-functions.js +++ b/tests/e2e/helpers/weather-functions.js @@ -1,12 +1,108 @@ -const { injectMockData, cleanupMockData } = require("../../utils/weather_mocker"); +const fs = require("node:fs"); +const path = require("node:path"); +const weatherUtils = require("../../../defaultmodules/weather/provider-utils"); const helpers = require("./global-setup"); -exports.startApplication = async (configFileName, additionalMockData) => { - await helpers.startApplication(injectMockData(configFileName, additionalMockData)); +/** + * Inject mock weather data directly via socket communication + * This bypasses the weather provider and tests only client-side rendering + * @param {object} page - Playwright page + * @param {string} mockDataFile - Filename of mock data + */ +async function injectMockWeatherData (page, mockDataFile) { + const rawData = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../mocks", mockDataFile)).toString()); + + // Validate that the fixture has at least one expected weather data type + if (!rawData.current && !rawData.daily && !rawData.hourly) { + throw new Error( + "Invalid weather fixture: missing current, daily, and hourly data. " + + `Available keys: ${Object.keys(rawData).join(", ")}` + ); + } + + // Determine weather type from the mock data structure + let type = "current"; + let data = null; + + const timezoneOffset = rawData.timezone_offset ? rawData.timezone_offset / 60 : 0; + + if (rawData.current) { + type = "current"; + // Mock what the provider would send for current weather + data = { + date: weatherUtils.applyTimezoneOffset(new Date(rawData.current.dt * 1000), timezoneOffset), + windSpeed: rawData.current.wind_speed, + windFromDirection: rawData.current.wind_deg, + sunrise: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunrise * 1000), timezoneOffset), + sunset: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunset * 1000), timezoneOffset), + temperature: rawData.current.temp, + weatherType: weatherUtils.convertWeatherType(rawData.current.weather[0].icon), + humidity: rawData.current.humidity, + feelsLikeTemp: rawData.current.feels_like + }; + } else if (rawData.daily) { + type = "forecast"; + data = rawData.daily.map((day) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset), + minTemperature: day.temp.min, + maxTemperature: day.temp.max, + weatherType: weatherUtils.convertWeatherType(day.weather[0].icon), + rain: day.rain || 0, + snow: day.snow || 0, + precipitationAmount: (day.rain || 0) + (day.snow || 0) + })); + } else if (rawData.hourly) { + type = "hourly"; + data = rawData.hourly.map((hour) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset), + temperature: hour.temp, + feelsLikeTemp: hour.feels_like, + humidity: hour.humidity, + windSpeed: hour.wind_speed, + windFromDirection: hour.wind_deg, + weatherType: weatherUtils.convertWeatherType(hour.weather[0].icon), + precipitationProbability: hour.pop != null ? hour.pop * 100 : undefined, + precipitationAmount: (hour.rain?.["1h"] || 0) + (hour.snow?.["1h"] || 0) + })); + } + + // Inject weather data by evaluating code in the browser context + await page.evaluate(({ type, data }) => { + // Find the weather module instance + const weatherModule = MM.getModules().find((m) => m.name === "weather"); + if (weatherModule) { + // Send INITIALIZED first + weatherModule.socketNotificationReceived("WEATHER_INITIALIZED", { + instanceId: weatherModule.instanceId, + locationName: "Munich" + }); + // Then send the actual data + weatherModule.socketNotificationReceived("WEATHER_DATA", { + instanceId: weatherModule.instanceId, + type: type, + data: data + }); + } + }, { type, data }); +} + +exports.startApplication = async (configFileName, mockDataFile) => { + await helpers.startApplication(configFileName); await helpers.getDocument(); + + // If mock data file is provided, inject it + if (mockDataFile) { + const page = helpers.getPage(); + // Wait for weather module to initialize + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector(".weather", { timeout: 5000 }); + await injectMockWeatherData(page, mockDataFile); + // Wait for data to be rendered + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector(".weather .weathericon", { timeout: 2000 }); + } }; exports.stopApplication = async () => { await helpers.stopApplication(); - cleanupMockData(); }; diff --git a/tests/e2e/ipWhitelist_spec.js b/tests/e2e/ipWhitelist_spec.js index 7d85d126b6..e83809c674 100644 --- a/tests/e2e/ipWhitelist_spec.js +++ b/tests/e2e/ipWhitelist_spec.js @@ -5,6 +5,7 @@ describe("ipWhitelist directive configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/noIpWhiteList.js"); }); + afterAll(async () => { await helpers.stopApplication(); }); @@ -20,6 +21,7 @@ describe("ipWhitelist directive configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/empty_ipWhiteList.js"); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/e2e/modules/compliments_spec.js b/tests/e2e/modules/compliments_spec.js index bb8f13a33b..ef35c8a895 100644 --- a/tests/e2e/modules/compliments_spec.js +++ b/tests/e2e/modules/compliments_spec.js @@ -104,6 +104,7 @@ describe("Compliments module", () => { await helpers.getDocument(); page = helpers.getPage(); }); + it("shows 'Remote compliment file works!' as only anytime list set", async () => { //await helpers.startApplication("tests/configs/modules/compliments/compliments_file.js", "01 Jan 2022 10:00:00 GMT"); await doTest(["Remote compliment file works!"]); @@ -119,6 +120,7 @@ describe("Compliments module", () => { await helpers.getDocument(); page = helpers.getPage(); }); + it("shows 'test in morning' as test time set to 10am", async () => { //await helpers.startApplication("tests/configs/modules/compliments/compliments_file_change.js", "01 Jan 2022 10:00:00 GMT"); await doTest(["Remote compliment file works!"]); diff --git a/tests/e2e/modules/newsfeed_spec.js b/tests/e2e/modules/newsfeed_spec.js index 4cee4a75b4..b68b6ea5a6 100644 --- a/tests/e2e/modules/newsfeed_spec.js +++ b/tests/e2e/modules/newsfeed_spec.js @@ -2,7 +2,7 @@ const fs = require("node:fs"); const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); -const runTests = async () => { +const runTests = () => { let page; describe("Default configuration", () => { @@ -70,6 +70,148 @@ const runTests = async () => { }); }; +describe("Newsfeed module > Notifications", () => { + let page; + + afterAll(async () => { + await helpers.stopApplication(); + }); + + /** + * Helper: call notificationReceived on the newsfeed module directly. + * @param {object} p - playwright page + * @param {string} notification - notification name + * @param {object} payload - notification payload + * @returns {Promise} resolves when the notification has been dispatched + */ + const notify = (p, notification, payload = {}) => p.evaluate( + ({ n, pl }) => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.notificationReceived(n, pl, nf); + }, + { n: notification, pl: payload } + ); + + beforeAll(async () => { + await helpers.startApplication("tests/configs/modules/newsfeed/notifications.js"); + await helpers.getDocument(); + page = helpers.getPage(); + await expect(page.locator(".newsfeed .newsfeed-title")).toBeVisible(); + }); + + it("ARTICLE_NEXT should show the next article", async () => { + const title1 = await page.locator(".newsfeed .newsfeed-title").textContent(); + await notify(page, "ARTICLE_NEXT"); + await expect(page.locator(".newsfeed .newsfeed-title")).not.toContainText(title1.trim()); + }); + + it("ARTICLE_PREVIOUS should return to the previous article", async () => { + // Start at article 0, go to article 1, then back + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = 0; + nf.resetDescrOrFullArticleAndTimer(); + nf.updateDom(0); + }); + await expect(page.locator(".newsfeed .newsfeed-title")).toContainText("QPanel"); + const title0 = await page.locator(".newsfeed .newsfeed-title").textContent(); + + await notify(page, "ARTICLE_NEXT"); + await expect(page.locator(".newsfeed .newsfeed-title")).not.toContainText(title0.trim()); + + await notify(page, "ARTICLE_PREVIOUS"); + await expect(page.locator(".newsfeed .newsfeed-title")).toContainText(title0.trim()); + }); + + it("ARTICLE_NEXT should wrap around from the last article to the first", async () => { + // Jump to the last article + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = nf.newsItems.length - 1; + nf.resetDescrOrFullArticleAndTimer(); + nf.updateDom(0); + }); + await expect(page.locator(".newsfeed .newsfeed-title")).toBeVisible(); + const titleLast = await page.locator(".newsfeed .newsfeed-title").textContent(); + + await notify(page, "ARTICLE_NEXT"); + await expect(page.locator(".newsfeed .newsfeed-title")).not.toContainText(titleLast.trim()); + + // activeItem should now be 0 + const activeItem = await page.evaluate(() => MM.getModules().find((m) => m.name === "newsfeed").activeItem); + expect(activeItem).toBe(0); + }); + + it("ARTICLE_PREVIOUS should wrap around from the first article to the last", async () => { + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = 0; + nf.resetDescrOrFullArticleAndTimer(); + }); + await notify(page, "ARTICLE_PREVIOUS"); + + const activeItem = await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + return { activeItem: nf.activeItem, total: nf.newsItems.length }; + }); + expect(activeItem.activeItem).toBe(activeItem.total - 1); + }); + + it("ARTICLE_INFO_REQUEST should respond with title, source, date, desc and raw url", async () => { + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = 0; + nf.resetDescrOrFullArticleAndTimer(); + }); + + const info = await page.evaluate(() => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("ARTICLE_INFO_RESPONSE timeout")), 3000); + const origSend = MM.sendNotification.bind(MM); + MM.sendNotification = function (n, p, s) { + if (n === "ARTICLE_INFO_RESPONSE") { + clearTimeout(timer); + MM.sendNotification = origSend; + resolve(p); + } + return origSend(n, p, s); + }; + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.notificationReceived("ARTICLE_INFO_REQUEST", {}, nf); + })); + + expect(info).toHaveProperty("title"); + expect(info).toHaveProperty("source"); + expect(info).toHaveProperty("date"); + expect(info).toHaveProperty("desc"); + expect(info).toHaveProperty("url"); + expect(info.title).toBe("QPanel 0.13.0"); + expect(info.source).toBe("Rodrigo Ramirez Blog"); + // URL must be the raw article URL, not a CORS proxy URL + expect(info.url).toMatch(/^https?:\/\//); + expect(info.url).not.toContain("localhost"); + }); + + it("ARTICLE_LESS_DETAILS should reset the full article view", async () => { + // Simulate full article view being active + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.config.showFullArticle = true; + nf.articleFrameCheckPending = false; + nf.articleUnavailable = false; + }); + + await notify(page, "ARTICLE_LESS_DETAILS"); + + const state = await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + return { showFullArticle: nf.config.showFullArticle }; + }); + expect(state.showFullArticle).toBe(false); + // Normal newsfeed title should be visible again + await expect(page.locator(".newsfeed .newsfeed-title")).toBeVisible(); + }); +}); + describe("Newsfeed module", () => { afterAll(async () => { await helpers.stopApplication(); @@ -80,7 +222,7 @@ describe("Newsfeed module", () => { describe("Newsfeed module located in config directory", () => { beforeAll(() => { - fs.cpSync(`${global.root_path}/modules/default/newsfeed`, `${global.root_path}/config/newsfeed`, { recursive: true }); + fs.cpSync(`${global.root_path}/${global.defaultModulesDir}/newsfeed`, `${global.root_path}/config/newsfeed`, { recursive: true }); process.env.MM_MODULES_DIR = "config"; }); diff --git a/tests/e2e/modules/weather_current_spec.js b/tests/e2e/modules/weather_current_spec.js index 9b2928792e..bb8b63e2ac 100644 --- a/tests/e2e/modules/weather_current_spec.js +++ b/tests/e2e/modules/weather_current_spec.js @@ -12,7 +12,7 @@ describe("Weather module", () => { describe("Current weather", () => { describe("Default configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_default.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_default.js", "weather_onecall_current.json"); page = helpers.getPage(); }); @@ -38,7 +38,7 @@ describe("Weather module", () => { describe("Compliments Integration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_compliments.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_compliments.js", "weather_onecall_current.json"); page = helpers.getPage(); }); @@ -51,7 +51,7 @@ describe("Weather module", () => { describe("Configuration Options", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_options.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_options.js", "weather_onecall_current.json"); page = helpers.getPage(); }); @@ -79,7 +79,7 @@ describe("Weather module", () => { describe("Current weather with imperial units", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_units.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_units.js", "weather_onecall_current.json"); page = helpers.getPage(); }); diff --git a/tests/e2e/modules/weather_forecast_spec.js b/tests/e2e/modules/weather_forecast_spec.js index 011ed35f49..435cc98ce5 100644 --- a/tests/e2e/modules/weather_forecast_spec.js +++ b/tests/e2e/modules/weather_forecast_spec.js @@ -11,7 +11,7 @@ describe("Weather module: Weather Forecast", () => { describe("Default configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_default.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_default.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); @@ -58,7 +58,7 @@ describe("Weather module: Weather Forecast", () => { describe("Absolute configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_absolute.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_absolute.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); @@ -73,7 +73,7 @@ describe("Weather module: Weather Forecast", () => { describe("Configuration Options", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_options.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_options.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); @@ -99,7 +99,7 @@ describe("Weather module: Weather Forecast", () => { describe("Forecast weather with imperial units", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_units.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_units.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); diff --git a/tests/e2e/modules/weather_hourly_spec.js b/tests/e2e/modules/weather_hourly_spec.js index a33503f3b2..d84bd69e72 100644 --- a/tests/e2e/modules/weather_hourly_spec.js +++ b/tests/e2e/modules/weather_hourly_spec.js @@ -11,7 +11,7 @@ describe("Weather module: Weather Hourly Forecast", () => { describe("Default configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_default.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_default.js", "weather_onecall_hourly.json"); page = helpers.getPage(); }); @@ -26,7 +26,7 @@ describe("Weather module: Weather Hourly Forecast", () => { describe("Hourly weather options", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_options.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_options.js", "weather_onecall_hourly.json"); page = helpers.getPage(); }); @@ -43,7 +43,7 @@ describe("Weather module: Weather Hourly Forecast", () => { describe("Show precipitations", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", "weather_onecall_hourly.json"); page = helpers.getPage(); }); diff --git a/tests/e2e/modules_display_spec.js b/tests/e2e/modules_display_spec.js index 698fe59262..fc4c992dfe 100644 --- a/tests/e2e/modules_display_spec.js +++ b/tests/e2e/modules_display_spec.js @@ -9,6 +9,7 @@ describe("Display of modules", () => { await helpers.getDocument(); page = helpers.getPage(); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/e2e/modules_empty_spec.js b/tests/e2e/modules_empty_spec.js index 3957dba743..18c5d302d5 100644 --- a/tests/e2e/modules_empty_spec.js +++ b/tests/e2e/modules_empty_spec.js @@ -9,6 +9,7 @@ describe("Check configuration without modules", () => { await helpers.getDocument(); page = helpers.getPage(); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/e2e/modules_position_spec.js b/tests/e2e/modules_position_spec.js index 30a5878911..ee4f14edf4 100644 --- a/tests/e2e/modules_position_spec.js +++ b/tests/e2e/modules_position_spec.js @@ -7,6 +7,7 @@ describe("Position of modules", () => { await helpers.startApplication("tests/configs/modules/positions.js"); await helpers.getDocument(); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/e2e/port_spec.js b/tests/e2e/port_spec.js index 4153e0cf9b..a64ac568b1 100644 --- a/tests/e2e/port_spec.js +++ b/tests/e2e/port_spec.js @@ -5,6 +5,7 @@ describe("port directive configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/port_8090.js"); }); + afterAll(async () => { await helpers.stopApplication(); }); @@ -20,6 +21,7 @@ describe("port directive configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/port_8090.js", (process.env.MM_PORT = 8100)); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/e2e/serveronly_spec.js b/tests/e2e/serveronly_spec.js index dbad0daa99..b793543966 100644 --- a/tests/e2e/serveronly_spec.js +++ b/tests/e2e/serveronly_spec.js @@ -20,6 +20,7 @@ describe("App environment", () => { // we have to wait until the server is started await delay(2000); }); + afterAll(async () => { await process.kill(-serverProcess.pid); }); diff --git a/tests/e2e/template_spec.js b/tests/e2e/template_spec.js deleted file mode 100644 index 832d7f355e..0000000000 --- a/tests/e2e/template_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -const fs = require("node:fs"); -const helpers = require("./helpers/global-setup"); - -describe("templated config with port variable", () => { - beforeAll(async () => { - await helpers.startApplication("tests/configs/port_variable.js"); - }); - afterAll(async () => { - await helpers.stopApplication(); - try { - fs.unlinkSync("tests/configs/port_variable.js"); - } catch (err) { - // do nothing - } - }); - - it("should return 200", async () => { - const port = global.testPort || 8080; - const res = await fetch(`http://localhost:${port}`); - expect(res.status).toBe(200); - }); -}); diff --git a/tests/e2e/vendor_spec.js b/tests/e2e/vendor_spec.js index 5d66928755..aca6f3f296 100644 --- a/tests/e2e/vendor_spec.js +++ b/tests/e2e/vendor_spec.js @@ -4,6 +4,7 @@ describe("Vendors", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/default.js"); }); + afterAll(async () => { await helpers.stopApplication(); }); diff --git a/tests/electron/helpers/global-setup.js b/tests/electron/helpers/global-setup.js index 7e9c74e7fe..91c542d090 100644 --- a/tests/electron/helpers/global-setup.js +++ b/tests/electron/helpers/global-setup.js @@ -15,7 +15,7 @@ exports.startApplication = async (configFilename, systemDate = null, electronPar // check environment for DISPLAY or WAYLAND_DISPLAY if (process.env.WAYLAND_DISPLAY) { - electronParams.unshift("js/electron.js", "--enable-features=UseOzonePlatform", "--ozone-platform=wayland"); + electronParams.unshift("js/electron.js", "--ozone-platform=wayland"); } else { electronParams.unshift("js/electron.js"); } diff --git a/tests/electron/helpers/weather-setup.js b/tests/electron/helpers/weather-setup.js index cb43054897..3bddaef1a1 100644 --- a/tests/electron/helpers/weather-setup.js +++ b/tests/electron/helpers/weather-setup.js @@ -1,6 +1,77 @@ -const { injectMockData } = require("../../utils/weather_mocker"); +const fs = require("node:fs"); +const path = require("node:path"); +const weatherUtils = require("../../../defaultmodules/weather/provider-utils"); const helpers = require("./global-setup"); +/** + * Inject mock weather data directly via socket communication + * This bypasses the weather provider and tests only client-side rendering + * @param {string} mockDataFile - Filename of mock data in tests/mocks + */ +async function injectMockWeatherData (mockDataFile) { + const rawData = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../mocks", mockDataFile)).toString()); + + const timezoneOffset = rawData.timezone_offset ? rawData.timezone_offset / 60 : 0; + + let type = "current"; + let data = null; + + if (rawData.current) { + type = "current"; + data = { + date: weatherUtils.applyTimezoneOffset(new Date(rawData.current.dt * 1000), timezoneOffset), + windSpeed: rawData.current.wind_speed, + windFromDirection: rawData.current.wind_deg, + sunrise: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunrise * 1000), timezoneOffset), + sunset: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunset * 1000), timezoneOffset), + temperature: rawData.current.temp, + weatherType: weatherUtils.convertWeatherType(rawData.current.weather[0].icon), + humidity: rawData.current.humidity, + feelsLikeTemp: rawData.current.feels_like + }; + } else if (rawData.daily) { + type = "forecast"; + data = rawData.daily.map((day) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset), + minTemperature: day.temp.min, + maxTemperature: day.temp.max, + weatherType: weatherUtils.convertWeatherType(day.weather[0].icon), + rain: day.rain || 0, + snow: day.snow || 0, + precipitationAmount: (day.rain || 0) + (day.snow || 0) + })); + } else if (rawData.hourly) { + type = "hourly"; + data = rawData.hourly.map((hour) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset), + temperature: hour.temp, + feelsLikeTemp: hour.feels_like, + humidity: hour.humidity, + windSpeed: hour.wind_speed, + windFromDirection: hour.wind_deg, + weatherType: weatherUtils.convertWeatherType(hour.weather[0].icon), + precipitationProbability: hour.pop != null ? hour.pop * 100 : undefined, + precipitationAmount: (hour.rain?.["1h"] || 0) + (hour.snow?.["1h"] || 0) + })); + } + + // Inject weather data by evaluating code in the browser context + await global.page.evaluate(({ type, data }) => { + const weatherModule = MM.getModules().find((m) => m.name === "weather"); + if (weatherModule) { + weatherModule.socketNotificationReceived("WEATHER_INITIALIZED", { + instanceId: weatherModule.instanceId, + locationName: "Munich" + }); + weatherModule.socketNotificationReceived("WEATHER_DATA", { + instanceId: weatherModule.instanceId, + type: type, + data: data + }); + } + }, { type, data }); +} + exports.getText = async (element, result) => { const elem = await helpers.getElement(element); await expect(elem).not.toBeNull(); @@ -14,6 +85,18 @@ exports.getText = async (element, result) => { return true; }; -exports.startApp = async (configFileName, systemDate) => { - await helpers.startApplication(injectMockData(configFileName), systemDate); +exports.startApp = async (configFileName, systemDate, mockDataFile = "weather_onecall_current.json") => { + await helpers.startApplication(configFileName, systemDate); + + // Wait for weather module to be present in DOM + await global.page.waitForSelector(".weather", { timeout: 5000 }); + + // Inject mock weather data + await injectMockWeatherData(mockDataFile); + + // Wait for weather content to be rendered + await global.page.waitForFunction(() => { + const weatherRoot = document.querySelector(".weather"); + return !!(weatherRoot && weatherRoot.textContent && weatherRoot.textContent.trim().length > 0); + }, { timeout: 5000 }); }; diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index 2a9845ae18..1be60c4d8f 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -50,8 +50,29 @@ describe("Calendar module", () => { return true; }; + const defaultCalendarNow = "08 Oct 2024 12:30:00 GMT-07:00"; + const defaultCalendarTimeZone = "America/Chicago"; + const showEndConfigPath = "tests/configs/modules/calendar/calendarShowEndConfigs.js"; + + const startCalendarShowEndScenario = async (scenario, now = defaultCalendarNow, timeZone = defaultCalendarTimeZone) => { + process.env.MM_CALENDAR_SHOWEND_SCENARIO = scenario; + await helpers.startApplication(showEndConfigPath, now, [], timeZone); + }; + + const expectFirstEventTimeCell = async ({ scenario, expectedTime, now = defaultCalendarNow, timeZone = defaultCalendarTimeZone }) => { + await startCalendarShowEndScenario(scenario, now, timeZone); + await expect(doTestTableContent(".calendar .event", ".time", expectedTime, first)).resolves.toBe(true); + }; + + const getFirstEventTimeText = async () => { + const timeCell = global.page.locator(".calendar .event .time").locator(`nth=${first}`); + await timeCell.waitFor({ state: "visible" }); + return (await timeCell.textContent()) || ""; + }; + afterEach(async () => { await helpers.stopApplication(); + delete process.env.MM_CALENDAR_SHOWEND_SCENARIO; }); describe("Test css classes", () => { @@ -283,7 +304,7 @@ describe("Calendar module", () => { describe("one event no end display", () => { it("don't display end", async () => { - await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); + await startCalendarShowEndScenario("event_with_time_over_multiple_days_non_repeating_no_display_end"); // just await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00", first)).resolves.toBe(true); }); @@ -291,12 +312,77 @@ describe("Calendar module", () => { describe("display end display end", () => { it("display end", async () => { - await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); + await startCalendarShowEndScenario("event_with_time_over_multiple_days_non_repeating_display_end"); // just await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00-26th.Oct, 06:00", first)).resolves.toBe(true); }); }); + describe("showEnd for timed multi-day events", () => { + const timedMultiDayCases = [ + { + name: "relative timeFormat shows start and end for timed multi-day events", + scenario: "event_with_time_over_multiple_days_non_repeating_display_end_relative", + expectedTime: "25th.Oct, 20:00-26th.Oct, 06:00" + }, + { + name: "dateheaders timeFormat shows end for timed multi-day events", + scenario: "event_with_time_over_multiple_days_non_repeating_display_end_dateheaders", + expectedTime: "20:00-06:00" + } + ]; + + it.each(timedMultiDayCases)("$name", async (testCase) => { + expect.hasAssertions(); + await expectFirstEventTimeCell(testCase); + }); + }); + + describe("showEnd for timed same-day events", () => { + const timedSameDaySimpleCases = [ + { + name: "absolute timeFormat shows start and end time without repeating date", + scenario: "event_with_time_same_day_yearly_display_end_absolute", + expectedTime: "25th.Oct, 20:00-22:00" + }, + { + name: "absolute timeFormat with time in dateFormat does not duplicate start time", + scenario: "event_with_time_same_day_yearly_display_end_absolute_dateformat_with_time", + expectedTime: "25th.Oct, 20:00-22:00" + }, + { + name: "relative timeFormat shows start and end time without repeating date", + scenario: "event_with_time_same_day_yearly_display_end_relative", + expectedTime: "25th.Oct, 20:00-22:00" + }, + { + name: "dateheaders timeFormat shows start and end time only", + scenario: "event_with_time_same_day_yearly_display_end_dateheaders", + expectedTime: "20:00-22:00" + } + ]; + + it.each(timedSameDaySimpleCases)("$name", async (testCase) => { + expect.hasAssertions(); + await expectFirstEventTimeCell(testCase); + }); + + it("absolute timeFormat with dateFormat LLL does not duplicate start time", async () => { + await startCalendarShowEndScenario("event_with_time_same_day_yearly_display_end_absolute_dateformat_lll"); + const timeText = await getFirstEventTimeText(); + const timeTokens = timeText.match(/\d{1,2}:\d{2}(?:\s?[AP]M)?/gi) || []; + expect(timeTokens).toHaveLength(2); + expect(timeText).toContain("-"); + }); + + it("relative timeFormat with hideTime does not show start or end times", async () => { + await startCalendarShowEndScenario("event_with_time_same_day_yearly_display_end_relative_hide_time"); + const timeText = await getFirstEventTimeText(); + expect(timeText).toContain("25th.Oct"); + expect(timeText.match(/\d{1,2}:\d{2}(?:\s?[AP]M)?/gi) || []).toHaveLength(0); + }); + }); + describe("count and check symbols", () => { it("in array", async () => { await helpers.startApplication("tests/configs/modules/calendar/symboltest.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago"); @@ -314,4 +400,30 @@ describe("Calendar module", () => { await expect(doTestTableContent(".testNotification", ".elementCount", "12", first)).resolves.toBe(true); }); }); + + describe("showEnd for multi-day full-day events", () => { + const fullDayShowEndCases = [ + { + name: "relative timeFormat shows start and end date", + scenario: "fullday_multiday_showend_relative", + expectedTime: "25th.Oct-30th.Oct" + }, + { + name: "dateheaders timeFormat shows end date in time cell", + scenario: "fullday_multiday_showend_dateheaders", + expectedTime: "-30th.Oct" + }, + { + name: "absolute timeFormat with nextDaysRelative shows relative label and end date", + scenario: "fullday_multiday_showend_nextdaysrelative", + expectedTime: "Tomorrow-30th.Oct", + now: "24 Oct 2024 12:30:00 GMT-07:00" + } + ]; + + it.each(fullDayShowEndCases)("$name", async (testCase) => { + expect.hasAssertions(); + await expectFirstEventTimeCell(testCase); + }); + }); }); diff --git a/tests/electron/modules/weather_spec.js b/tests/electron/modules/weather_spec.js index fb362f805f..e300da90de 100644 --- a/tests/electron/modules/weather_spec.js +++ b/tests/electron/modules/weather_spec.js @@ -1,6 +1,5 @@ const helpers = require("../helpers/global-setup"); const weatherHelper = require("../helpers/weather-setup"); -const { cleanupMockData } = require("../../utils/weather_mocker"); const CURRENT_WEATHER_CONFIG = "tests/configs/modules/weather/currentweather_default.js"; const SUNRISE_DATE = "13 Jan 2019 00:30:00 GMT"; @@ -12,7 +11,6 @@ const EXPECTED_SUNSET_TEXT = "3:45 pm"; describe("Weather module", () => { afterEach(async () => { await helpers.stopApplication(); - cleanupMockData(); }); describe("Current weather with sunrise", () => { diff --git a/tests/mocks/event_with_time_over_multiple_days_yearly.ics b/tests/mocks/event_with_time_over_multiple_days_yearly.ics new file mode 100644 index 0000000000..00f65b2f1d --- /dev/null +++ b/tests/mocks/event_with_time_over_multiple_days_yearly.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//MagicMirror Test//timed-multiday-yearly//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART:20241026T010000Z +DTEND:20241026T110000Z +DTSTAMP:20241024T153358Z +UID:4maud6s79m41a99pj2g7j5km0a@google.com +CREATED:20241024T153313Z +LAST-MODIFIED:20241024T153330Z +SEQUENCE:0 +STATUS:CONFIRMED +RRULE:FREQ=YEARLY +SUMMARY:Sleep over at Bobs +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/tests/mocks/event_with_time_same_day_yearly.ics b/tests/mocks/event_with_time_same_day_yearly.ics new file mode 100644 index 0000000000..4e0bb85185 --- /dev/null +++ b/tests/mocks/event_with_time_same_day_yearly.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//MagicMirror Test//timed-same-day-yearly//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART:20241025T200000 +DTEND:20241025T220000 +DTSTAMP:20241024T153358Z +UID:timed-same-day-yearly@magicmirror +CREATED:20241024T153313Z +LAST-MODIFIED:20241024T153330Z +SEQUENCE:0 +STATUS:CONFIRMED +RRULE:FREQ=YEARLY +SUMMARY:Same day timed event +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/tests/mocks/weather_envcanada.xml b/tests/mocks/weather_envcanada.xml new file mode 100644 index 0000000000..ac8d6fcff3 --- /dev/null +++ b/tests/mocks/weather_envcanada.xml @@ -0,0 +1,871 @@ + + + https://dd.weather.gc.ca/doc/LICENCE_GENERAL.txt + + 2026 + 02 + 07 + 12 + 04 + 20260207120421 + Saturday February 07, 2026 at 12:04 UTC + + + 2026 + 02 + 07 + 07 + 04 + 20260207070421 + Saturday February 07, 2026 at 07:04 EST + + + North America + Canada + Ontario + Toronto + City of Toronto + + + + + 2026 + 02 + 07 + 09 + 06 + 20260207090653 + Saturday February 07, 2026 at 09:06 UTC + + + 2026 + 02 + 07 + 04 + 06 + 20260207040653 + Saturday February 07, 2026 at 04:06 EST + + + + + Toronto Pearson Int'l Airport + + 2026 + 02 + 07 + 12 + 00 + 20260207120000 + Saturday February 07, 2026 at 12:00 UTC + + + 2026 + 02 + 07 + 07 + 00 + 20260207070000 + Saturday February 07, 2026 at 07:00 EST + + Blowing Snow + 40 + -20.3 + -24.9 + -31 + 102.1 + 9.7 + 67 + + 19 + 33 + NNW + 346.0 + + + + + 2026 + 02 + 07 + 10 + 00 + 20260207100000 + Saturday February 07, 2026 at 10:00 UTC + + + 2026 + 02 + 07 + 05 + 00 + 20260207050000 + Saturday February 07, 2026 at 05:00 EST + + + Low minus 9. High minus 2. + -2 + -9 + + + Saturday + A mix of sun and cloud. 40 percent chance of flurries early this morning. Wind northwest 30 km/h gusting to 50. High minus 13. Wind chill minus 33 this morning and minus 22 this afternoon. Risk of frostbite. UV index 1 or low. + + A mix of sun and cloud. 40 percent chance of flurries early this morning. + + + 08 + 40 + Chance of flurries + + + High minus 13. + -13 + + + Wind northwest 30 km/h gusting to 50. + + 30 + 50 + NW + 32 + + + + + snow + + + Wind chill minus 33 this morning and minus 22 this afternoon. Risk of frostbite. + -33 + -22 + Risk of frostbite + + + 1 + UV index 1 or low. + + 40 + + + + Saturday night + Partly cloudy. Clearing late this evening. Wind northwest 20 km/h becoming light late this evening. Low minus 21. Wind chill minus 22 this evening and minus 28 overnight. Risk of frostbite. + + Partly cloudy. Clearing late this evening. + + + 35 + + Clearing + + + Low minus 21. + -21 + + + Wind northwest 20 km/h becoming light late this evening. + + 20 + 00 + NW + 32 + + + 10 + 00 + NW + 32 + + + + + + + + Wind chill minus 22 this evening and minus 28 overnight. Risk of frostbite. + -22 + -28 + Risk of frostbite + + 65 + + + + Sunday + Sunny. Wind up to 15 km/h. High minus 12. Wind chill minus 28 in the morning and minus 19 in the afternoon. Risk of frostbite. UV index 2 or low. + + Sunny. + + + 00 + + Sunny + + + High minus 12. + -12 + + + Wind up to 15 km/h. + + 10 + 00 + N + 36 + + + 15 + 00 + NW + 32 + + + + + + + + Wind chill minus 28 in the morning and minus 19 in the afternoon. Risk of frostbite. + -28 + -19 + Risk of frostbite + + + 2 + UV index 2 or low. + + 55 + + + + Sunday night + Cloudy periods. Low minus 14. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 14. + -14 + + + + + + + + 60 + + + + Monday + Cloudy with 40 percent chance of flurries. High minus 6. + + Cloudy with 40 percent chance of flurries. + + + 16 + 40 + Chance of flurries + + + High minus 6. + -6 + + + + + snow + + + 65 + + + + Monday night + Cloudy with 30 percent chance of flurries. Low minus 8. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + Low minus 8. + -8 + + + + + snow + + + 65 + + + + Tuesday + Cloudy with 30 percent chance of flurries. High minus 2. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + High minus 2. + -2 + + + + + snow + + + 75 + + + + Tuesday night + Cloudy with 40 percent chance of flurries. Low minus 3. + + Cloudy with 40 percent chance of flurries. + + + 16 + 40 + Chance of flurries + + + Low minus 3. + -3 + + + + + snow + + + 80 + + + + Wednesday + Cloudy with 30 percent chance of flurries. High zero. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + High zero. + 0 + + + + + snow + + + 80 + + + + Wednesday night + Cloudy periods. Low minus 6. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 6. + -6 + + + + + + + + 90 + + + + Thursday + A mix of sun and cloud. High minus 2. + + A mix of sun and cloud. + + + 02 + + A mix of sun and cloud + + + High minus 2. + -2 + + + + + + + + 75 + + + + Thursday night + Cloudy periods. Low minus 7. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 7. + -7 + + + + + + + + 75 + + + + Friday + A mix of sun and cloud. High minus 1. + + A mix of sun and cloud. + + + 02 + + A mix of sun and cloud + + + High minus 1. + -1 + + + + + + + + 75 + + + + + + 2026 + 02 + 07 + 10 + 00 + 20260207100000 + Saturday February 07, 2026 at 10:00 UTC + + + 2026 + 02 + 07 + 05 + 00 + 20260207050000 + Saturday February 07, 2026 at 05:00 EST + + + A mix of sun and cloud + 02 + -20 + 10 + -32 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -19 + 10 + -32 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -19 + 10 + -31 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -18 + 10 + -30 + + + 30 + NW + 50 + + + 1 + + + + Mainly sunny + 01 + -16 + 10 + -28 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -15 + 10 + -26 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -14 + 10 + -25 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -14 + 10 + -24 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -13 + 10 + -23 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -13 + 10 + -24 + + + 30 + NW + 50 + + + + Partly cloudy + 32 + -14 + 10 + -22 + + + 20 + NW + + + + + Partly cloudy + 32 + -14 + 10 + -23 + + + 20 + NW + + + + + Partly cloudy + 32 + -15 + 10 + -24 + + + 20 + NW + + + + + Partly cloudy + 32 + -16 + 0 + -25 + + + 20 + NW + + + + + Partly cloudy + 32 + -17 + 0 + -24 + + + 10 + NW + + + + + A few clouds + 31 + -18 + 0 + -24 + + + 10 + NW + + + + + A few clouds + 31 + -18 + 0 + -25 + + + 10 + NW + + + + + A few clouds + 31 + -19 + 0 + -26 + + + 10 + NW + + + + + Clear + 30 + -20 + 0 + -27 + + + 10 + NW + + + + + Clear + 30 + -20 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + N + + + + + Sunny + 00 + -21 + 0 + -28 + + + 10 + N + + + + + + The information provided here, for the times of the rise and set of the sun, is an estimate included as a convenience to our clients. Values shown here may differ from the official sunrise/sunset data available from (http://hia-iha.nrc-cnrc.gc.ca/sunrise_e.html) + + 2026 + 02 + 07 + 12 + 27 + 20260207122700 + Saturday February 07, 2026 at 12:27 UTC + + + 2026 + 02 + 07 + 07 + 27 + 20260207072700 + Saturday February 07, 2026 at 07:27 EST + + + 2026 + 02 + 07 + 22 + 37 + 20260207223700 + Saturday February 07, 2026 at 22:37 UTC + + + 2026 + 02 + 07 + 17 + 37 + 20260207173700 + Saturday February 07, 2026 at 17:37 EST + + + \ No newline at end of file diff --git a/tests/mocks/weather_envcanada_index.html b/tests/mocks/weather_envcanada_index.html new file mode 100644 index 0000000000..93d5a5d4a2 --- /dev/null +++ b/tests/mocks/weather_envcanada_index.html @@ -0,0 +1,427 @@ + + + + Index of /today/citypage_weather/ON/12 + + +

Index of /today/citypage_weather/ON/12

+
Icon  Name                                                     Last modified      Size  Description
[PARENTDIR] Parent Directory - +[   ] 20260207T120044.778Z_MSC_CitypageWeather_s0000024_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.827Z_MSC_CitypageWeather_s0000024_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.875Z_MSC_CitypageWeather_s0000022_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.919Z_MSC_CitypageWeather_s0000022_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120045.458Z_MSC_CitypageWeather_s0000588_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120045.502Z_MSC_CitypageWeather_s0000588_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.636Z_MSC_CitypageWeather_s0000546_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120047.636Z_MSC_CitypageWeather_s0000819_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.715Z_MSC_CitypageWeather_s0000546_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120047.715Z_MSC_CitypageWeather_s0000819_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120047.911Z_MSC_CitypageWeather_s0000646_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.960Z_MSC_CitypageWeather_s0000646_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000512_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000513_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000790_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000512_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000513_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000790_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.664Z_MSC_CitypageWeather_s0000765_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120048.664Z_MSC_CitypageWeather_s0000766_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.752Z_MSC_CitypageWeather_s0000765_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.752Z_MSC_CitypageWeather_s0000766_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.945Z_MSC_CitypageWeather_s0000782_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.994Z_MSC_CitypageWeather_s0000782_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.038Z_MSC_CitypageWeather_s0000585_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.038Z_MSC_CitypageWeather_s0000785_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.127Z_MSC_CitypageWeather_s0000585_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.127Z_MSC_CitypageWeather_s0000785_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000411_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000659_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000660_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000411_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000659_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000660_fr.xml 2026-02-07 12:00 38K +[   ] 20260207T120052.583Z_MSC_CitypageWeather_s0000080_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.583Z_MSC_CitypageWeather_s0000454_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.665Z_MSC_CitypageWeather_s0000080_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.665Z_MSC_CitypageWeather_s0000454_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120055.400Z_MSC_CitypageWeather_s0000596_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120055.400Z_MSC_CitypageWeather_s0000597_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120055.471Z_MSC_CitypageWeather_s0000596_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120055.471Z_MSC_CitypageWeather_s0000597_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120117.907Z_MSC_CitypageWeather_s0000744_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120117.907Z_MSC_CitypageWeather_s0000796_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120118.045Z_MSC_CitypageWeather_s0000744_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120118.045Z_MSC_CitypageWeather_s0000796_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120118.904Z_MSC_CitypageWeather_s0000572_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120118.904Z_MSC_CitypageWeather_s0000573_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.006Z_MSC_CitypageWeather_s0000572_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.006Z_MSC_CitypageWeather_s0000573_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.181Z_MSC_CitypageWeather_s0000765_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.181Z_MSC_CitypageWeather_s0000766_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.228Z_MSC_CitypageWeather_s0000765_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.228Z_MSC_CitypageWeather_s0000766_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.276Z_MSC_CitypageWeather_s0000395_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.276Z_MSC_CitypageWeather_s0000520_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.351Z_MSC_CitypageWeather_s0000395_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.351Z_MSC_CitypageWeather_s0000520_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000724_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000725_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000726_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000727_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000728_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000724_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000725_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000726_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000727_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000728_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120120.713Z_MSC_CitypageWeather_s0000752_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120120.713Z_MSC_CitypageWeather_s0000753_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120120.815Z_MSC_CitypageWeather_s0000752_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120120.815Z_MSC_CitypageWeather_s0000753_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.465Z_MSC_CitypageWeather_s0000538_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.465Z_MSC_CitypageWeather_s0000539_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.544Z_MSC_CitypageWeather_s0000538_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.544Z_MSC_CitypageWeather_s0000539_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000637_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000638_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000639_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000640_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000641_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000642_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000637_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000638_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000639_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000640_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000641_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000642_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000707_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000708_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000710_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000707_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000708_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000710_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120124.159Z_MSC_CitypageWeather_s0000628_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120124.217Z_MSC_CitypageWeather_s0000628_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000629_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000631_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000632_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000629_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000631_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000632_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.136Z_MSC_CitypageWeather_s0000650_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120126.136Z_MSC_CitypageWeather_s0000651_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120126.234Z_MSC_CitypageWeather_s0000650_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.234Z_MSC_CitypageWeather_s0000651_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.402Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120126.402Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120126.591Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120126.591Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000696_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000697_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000698_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000696_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000697_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000698_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.975Z_MSC_CitypageWeather_s0000374_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.092Z_MSC_CitypageWeather_s0000374_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000761_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000762_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000764_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000761_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000762_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000764_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120129.744Z_MSC_CitypageWeather_s0000676_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120129.744Z_MSC_CitypageWeather_s0000677_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120129.871Z_MSC_CitypageWeather_s0000676_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120129.871Z_MSC_CitypageWeather_s0000677_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120130.576Z_MSC_CitypageWeather_s0000069_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120131.204Z_MSC_CitypageWeather_s0000069_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120131.735Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120131.735Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120131.790Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120131.790Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.173Z_MSC_CitypageWeather_s0000232_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.173Z_MSC_CitypageWeather_s0000233_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.291Z_MSC_CitypageWeather_s0000232_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120132.291Z_MSC_CitypageWeather_s0000233_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000325_en.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000326_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000327_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000328_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000329_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000325_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000326_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000327_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000328_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000329_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120133.465Z_MSC_CitypageWeather_s0000676_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.465Z_MSC_CitypageWeather_s0000677_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120133.514Z_MSC_CitypageWeather_s0000676_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.514Z_MSC_CitypageWeather_s0000677_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000761_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000762_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000764_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000761_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000762_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000764_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000424_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000425_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000426_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000424_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000425_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000426_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000724_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000725_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000726_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000727_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000728_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000724_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000725_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000726_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000727_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000728_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.808Z_MSC_CitypageWeather_s0000826_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120139.851Z_MSC_CitypageWeather_s0000826_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120147.835Z_MSC_CitypageWeather_s0000548_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120147.835Z_MSC_CitypageWeather_s0000549_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120147.928Z_MSC_CitypageWeather_s0000548_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120147.928Z_MSC_CitypageWeather_s0000549_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120148.018Z_MSC_CitypageWeather_s0000747_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120148.018Z_MSC_CitypageWeather_s0000748_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120148.089Z_MSC_CitypageWeather_s0000747_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120148.089Z_MSC_CitypageWeather_s0000748_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120148.161Z_MSC_CitypageWeather_s0000231_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120148.194Z_MSC_CitypageWeather_s0000231_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120150.484Z_MSC_CitypageWeather_s0000071_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120150.649Z_MSC_CitypageWeather_s0000071_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120153.235Z_MSC_CitypageWeather_s0000023_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.276Z_MSC_CitypageWeather_s0000023_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000072_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000077_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000434_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000435_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000436_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000072_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000077_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000434_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000435_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000436_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120153.926Z_MSC_CitypageWeather_s0000437_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.960Z_MSC_CitypageWeather_s0000437_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000073_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000074_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000075_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000073_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000074_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000075_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120205.167Z_MSC_CitypageWeather_s0000428_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120205.205Z_MSC_CitypageWeather_s0000428_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120207.885Z_MSC_CitypageWeather_s0000680_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120207.885Z_MSC_CitypageWeather_s0000843_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120207.961Z_MSC_CitypageWeather_s0000680_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120207.961Z_MSC_CitypageWeather_s0000843_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120210.405Z_MSC_CitypageWeather_s0000455_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120210.451Z_MSC_CitypageWeather_s0000455_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120210.675Z_MSC_CitypageWeather_s0000367_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120210.675Z_MSC_CitypageWeather_s0000368_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120210.758Z_MSC_CitypageWeather_s0000367_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120210.758Z_MSC_CitypageWeather_s0000368_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120211.358Z_MSC_CitypageWeather_s0000251_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120211.408Z_MSC_CitypageWeather_s0000251_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120221.954Z_MSC_CitypageWeather_s0000418_en.xml 2026-02-07 12:02 35K +[   ] 20260207T120221.954Z_MSC_CitypageWeather_s0000419_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.043Z_MSC_CitypageWeather_s0000418_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120222.043Z_MSC_CitypageWeather_s0000419_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.347Z_MSC_CitypageWeather_s0000451_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.347Z_MSC_CitypageWeather_s0000452_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.448Z_MSC_CitypageWeather_s0000451_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120222.448Z_MSC_CitypageWeather_s0000452_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.004Z_MSC_CitypageWeather_s0000550_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.050Z_MSC_CitypageWeather_s0000550_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.502Z_MSC_CitypageWeather_s0000763_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.539Z_MSC_CitypageWeather_s0000763_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.577Z_MSC_CitypageWeather_s0000469_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.577Z_MSC_CitypageWeather_s0000470_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.660Z_MSC_CitypageWeather_s0000469_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120223.660Z_MSC_CitypageWeather_s0000470_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120223.753Z_MSC_CitypageWeather_s0000103_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.753Z_MSC_CitypageWeather_s0000105_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.839Z_MSC_CitypageWeather_s0000103_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.839Z_MSC_CitypageWeather_s0000105_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000528_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000529_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000530_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000531_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000528_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000529_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000530_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000531_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000582_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000584_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000773_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000582_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000584_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000773_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000235_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000236_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000237_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000238_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000240_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000235_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000236_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000237_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000238_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000240_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.829Z_MSC_CitypageWeather_s0000127_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120228.874Z_MSC_CitypageWeather_s0000127_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000700_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000701_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000702_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000703_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000704_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000700_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000701_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000702_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000703_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000704_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120240.742Z_MSC_CitypageWeather_s0000104_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120240.780Z_MSC_CitypageWeather_s0000104_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120252.049Z_MSC_CitypageWeather_s0000431_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120252.088Z_MSC_CitypageWeather_s0000431_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120253.001Z_MSC_CitypageWeather_s0000169_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120253.050Z_MSC_CitypageWeather_s0000169_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000108_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000429_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000489_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000108_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000429_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000489_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120305.479Z_MSC_CitypageWeather_s0000705_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120305.479Z_MSC_CitypageWeather_s0000706_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120305.555Z_MSC_CitypageWeather_s0000705_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120305.555Z_MSC_CitypageWeather_s0000706_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120307.995Z_MSC_CitypageWeather_s0000517_en.xml 2026-02-07 12:03 35K +[   ] 20260207T120308.045Z_MSC_CitypageWeather_s0000517_fr.xml 2026-02-07 12:03 36K +[   ] 20260207T120312.670Z_MSC_CitypageWeather_s0000076_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120312.703Z_MSC_CitypageWeather_s0000076_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000281_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000414_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000415_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000281_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000414_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000415_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000301_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000302_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000303_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000304_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000305_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000301_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000302_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000303_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000304_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000305_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120324.150Z_MSC_CitypageWeather_s0000168_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120324.203Z_MSC_CitypageWeather_s0000168_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120324.794Z_MSC_CitypageWeather_s0000165_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120324.794Z_MSC_CitypageWeather_s0000166_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120324.879Z_MSC_CitypageWeather_s0000165_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120324.879Z_MSC_CitypageWeather_s0000166_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120326.004Z_MSC_CitypageWeather_s0000266_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120326.004Z_MSC_CitypageWeather_s0000267_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120326.077Z_MSC_CitypageWeather_s0000266_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120326.077Z_MSC_CitypageWeather_s0000267_fr.xml 2026-02-07 12:03 36K +[   ] 20260207T120334.963Z_MSC_CitypageWeather_s0000571_en.xml 2026-02-07 12:04 38K +[   ] 20260207T120335.008Z_MSC_CitypageWeather_s0000571_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120407.043Z_MSC_CitypageWeather_s0000422_en.xml 2026-02-07 12:04 38K +[   ] 20260207T120407.081Z_MSC_CitypageWeather_s0000422_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120410.902Z_MSC_CitypageWeather_s0000070_en.xml 2026-02-07 12:04 36K +[   ] 20260207T120410.951Z_MSC_CitypageWeather_s0000070_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:05 38K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000729_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000730_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000731_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000732_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000729_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000730_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000731_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000732_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.127Z_MSC_CitypageWeather_s0000430_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.127Z_MSC_CitypageWeather_s0000623_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.217Z_MSC_CitypageWeather_s0000430_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.217Z_MSC_CitypageWeather_s0000623_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000025_en.xml 2026-02-07 12:05 34K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000025_fr.xml 2026-02-07 12:05 35K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000088_en.xml 2026-02-07 12:05 34K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000088_fr.xml 2026-02-07 12:05 35K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000239_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000239_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000282_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000282_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000283_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000283_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000479_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000479_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000630_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000630_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000815_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000815_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000528_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000529_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000530_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000531_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000528_fr.xml 2026-02-07 12:06 38K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000529_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000530_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000531_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120612.072Z_MSC_CitypageWeather_s0000479_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120612.109Z_MSC_CitypageWeather_s0000479_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120622.544Z_MSC_CitypageWeather_s0000630_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120622.567Z_MSC_CitypageWeather_s0000630_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120622.795Z_MSC_CitypageWeather_s0000782_en.xml 2026-02-07 12:07 36K +[   ] 20260207T120622.821Z_MSC_CitypageWeather_s0000782_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120723.672Z_MSC_CitypageWeather_s0000588_en.xml 2026-02-07 12:08 36K +[   ] 20260207T120723.699Z_MSC_CitypageWeather_s0000588_fr.xml 2026-02-07 12:08 36K +[   ] 20260207T120740.860Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:08 38K +[   ] 20260207T120740.860Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:08 38K +[   ] 20260207T120740.911Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:08 39K +[   ] 20260207T120740.911Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:08 39K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:15 38K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:15 39K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T122801.995Z_MSC_CitypageWeather_s0000430_en.xml 2026-02-07 12:28 37K +[   ] 20260207T122801.995Z_MSC_CitypageWeather_s0000623_en.xml 2026-02-07 12:28 37K +[   ] 20260207T122802.053Z_MSC_CitypageWeather_s0000430_fr.xml 2026-02-07 12:28 38K +[   ] 20260207T122802.053Z_MSC_CitypageWeather_s0000623_fr.xml 2026-02-07 12:28 38K +[   ] 20260207T124745.464Z_MSC_CitypageWeather_s0000752_en.xml 2026-02-07 12:47 36K +[   ] 20260207T124745.464Z_MSC_CitypageWeather_s0000753_en.xml 2026-02-07 12:47 36K +[   ] 20260207T124745.526Z_MSC_CitypageWeather_s0000752_fr.xml 2026-02-07 12:47 37K +[   ] 20260207T124745.526Z_MSC_CitypageWeather_s0000753_fr.xml 2026-02-07 12:47 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:48 38K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:48 39K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:48 37K +
+ + diff --git a/tests/mocks/weather_onecall_current.json b/tests/mocks/weather_onecall_current.json new file mode 100644 index 0000000000..73b88d7a11 --- /dev/null +++ b/tests/mocks/weather_onecall_current.json @@ -0,0 +1,28 @@ +{ + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 0, + "current": { + "dt": 1547387400, + "sunrise": 1547362817, + "sunset": 1547394301, + "temp": 1.49, + "feels_like": -5.6, + "pressure": 1005, + "humidity": 93.7, + "uvi": 0, + "clouds": 75, + "visibility": 7000, + "wind_speed": 11.8, + "wind_deg": 250, + "weather": [ + { + "id": 615, + "main": "Snow", + "description": "light rain and snow", + "icon": "13d" + } + ] + } +} diff --git a/tests/mocks/weather_onecall_forecast.json b/tests/mocks/weather_onecall_forecast.json new file mode 100644 index 0000000000..aa70f6e597 --- /dev/null +++ b/tests/mocks/weather_onecall_forecast.json @@ -0,0 +1,149 @@ +{ + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 3600, + "daily": [ + { + "dt": 1568372400, + "sunrise": 1568350044, + "sunset": 1568395948, + "temp": { + "day": 24.44, + "min": 15.35, + "max": 24.44, + "night": 15.35, + "eve": 18, + "morn": 23.03 + }, + "pressure": 1031, + "humidity": 70, + "wind_speed": 3.35, + "wind_deg": 314, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": 21, + "pop": 0, + "uvi": 5 + }, + { + "dt": 1568458800, + "sunrise": 1568436525, + "sunset": 1568482223, + "temp": { + "day": 20.81, + "min": 13.56, + "max": 21.02, + "night": 13.56, + "eve": 16.6, + "morn": 15.88 + }, + "pressure": 1028, + "humidity": 72, + "wind_speed": 2.78, + "wind_deg": 266, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "clouds": 21, + "pop": 0.56, + "rain": 2.51, + "uvi": 4.5 + }, + { + "dt": 1568545200, + "sunrise": 1568523006, + "sunset": 1568568498, + "temp": { + "day": 22.93, + "min": 13.78, + "max": 22.93, + "night": 13.78, + "eve": 17.21, + "morn": 14.56 + }, + "pressure": 1024, + "humidity": 59, + "wind_speed": 2.17, + "wind_deg": 255, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 5.2 + }, + { + "dt": 1568631600, + "sunrise": 1568609487, + "sunset": 1568654774, + "temp": { + "day": 23.39, + "min": 13.93, + "max": 23.39, + "night": 13.93, + "eve": 17.98, + "morn": 15.05 + }, + "pressure": 1023, + "humidity": 57, + "wind_speed": 1.93, + "wind_deg": 236, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 5.1 + }, + { + "dt": 1568718000, + "sunrise": 1568695968, + "sunset": 1568741049, + "temp": { + "day": 20.64, + "min": 10.87, + "max": 20.64, + "night": 10.87, + "eve": 15.21, + "morn": 13.67 + }, + "pressure": 1021, + "humidity": 64, + "wind_speed": 2.44, + "wind_deg": 284, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 3, + "pop": 0, + "uvi": 4.9 + } + ] +} diff --git a/tests/mocks/weather_hourly.json b/tests/mocks/weather_onecall_hourly.json similarity index 99% rename from tests/mocks/weather_hourly.json rename to tests/mocks/weather_onecall_hourly.json index b0b2e66245..bcf2b806f6 100644 --- a/tests/mocks/weather_hourly.json +++ b/tests/mocks/weather_onecall_hourly.json @@ -1,7 +1,11 @@ { + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 3600, "hourly": [ { - "dt": 1673204400, + "dt": 1673200800, "temp": 27.31, "feels_like": 29.59, "pressure": 1013, @@ -24,7 +28,7 @@ "pop": 0 }, { - "dt": 1673208000, + "dt": 1673204400, "temp": 27.31, "feels_like": 29.69, "pressure": 1013, @@ -47,7 +51,7 @@ "pop": 0 }, { - "dt": 1673211600, + "dt": 1673208000, "temp": 27.29, "feels_like": 29.65, "pressure": 1013, @@ -70,7 +74,7 @@ "pop": 0.12 }, { - "dt": 1673215200, + "dt": 1673211600, "temp": 27.21, "feels_like": 29.6, "pressure": 1013, @@ -96,7 +100,7 @@ } }, { - "dt": 1673218800, + "dt": 1673215200, "temp": 27.1, "feels_like": 29.39, "pressure": 1014, @@ -122,7 +126,7 @@ } }, { - "dt": 1673222400, + "dt": 1673218800, "temp": 26.95, "feels_like": 29.19, "pressure": 1013, @@ -145,7 +149,7 @@ "pop": 0.52 }, { - "dt": 1673226000, + "dt": 1673222400, "temp": 26.72, "feels_like": 28.83, "pressure": 1012, @@ -168,7 +172,7 @@ "pop": 0.08 }, { - "dt": 1673229600, + "dt": 1673226000, "temp": 26.57, "feels_like": 26.57, "pressure": 1012, @@ -191,7 +195,7 @@ "pop": 0.08 }, { - "dt": 1673233200, + "dt": 1673229600, "temp": 26.46, "feels_like": 26.46, "pressure": 1011, @@ -214,7 +218,7 @@ "pop": 0.04 }, { - "dt": 1673236800, + "dt": 1673233200, "temp": 26.38, "feels_like": 26.38, "pressure": 1011, @@ -237,7 +241,7 @@ "pop": 0 }, { - "dt": 1673240400, + "dt": 1673236800, "temp": 26.32, "feels_like": 26.32, "pressure": 1012, @@ -260,7 +264,7 @@ "pop": 0 }, { - "dt": 1673244000, + "dt": 1673240400, "temp": 26.32, "feels_like": 26.32, "pressure": 1012, @@ -283,7 +287,7 @@ "pop": 0 }, { - "dt": 1673247600, + "dt": 1673244000, "temp": 26.44, "feels_like": 26.44, "pressure": 1013, @@ -306,7 +310,7 @@ "pop": 0 }, { - "dt": 1673251200, + "dt": 1673247600, "temp": 26.45, "feels_like": 26.45, "pressure": 1013, @@ -329,7 +333,7 @@ "pop": 0 }, { - "dt": 1673254800, + "dt": 1673251200, "temp": 26.54, "feels_like": 26.54, "pressure": 1014, @@ -352,7 +356,7 @@ "pop": 0 }, { - "dt": 1673258400, + "dt": 1673254800, "temp": 26.61, "feels_like": 26.61, "pressure": 1013, @@ -375,7 +379,7 @@ "pop": 0 }, { - "dt": 1673262000, + "dt": 1673258400, "temp": 26.76, "feels_like": 28.9, "pressure": 1013, @@ -398,7 +402,7 @@ "pop": 0 }, { - "dt": 1673265600, + "dt": 1673262000, "temp": 26.91, "feels_like": 29.11, "pressure": 1012, @@ -421,7 +425,7 @@ "pop": 0 }, { - "dt": 1673269200, + "dt": 1673265600, "temp": 27.04, "feels_like": 29.27, "pressure": 1011, @@ -444,7 +448,7 @@ "pop": 0 }, { - "dt": 1673272800, + "dt": 1673269200, "temp": 27.12, "feels_like": 29.33, "pressure": 1011, @@ -467,7 +471,7 @@ "pop": 0 }, { - "dt": 1673276400, + "dt": 1673272800, "temp": 27.17, "feels_like": 29.33, "pressure": 1010, @@ -490,7 +494,7 @@ "pop": 0 }, { - "dt": 1673280000, + "dt": 1673276400, "temp": 27.28, "feels_like": 29.43, "pressure": 1011, @@ -513,7 +517,7 @@ "pop": 0 }, { - "dt": 1673283600, + "dt": 1673280000, "temp": 27.28, "feels_like": 29.43, "pressure": 1011, @@ -536,7 +540,7 @@ "pop": 0 }, { - "dt": 1673287200, + "dt": 1673283600, "temp": 27.34, "feels_like": 29.54, "pressure": 1012, @@ -559,7 +563,7 @@ "pop": 0 }, { - "dt": 1673290800, + "dt": 1673287200, "temp": 27.25, "feels_like": 29.38, "pressure": 1013, @@ -582,7 +586,7 @@ "pop": 0 }, { - "dt": 1673294400, + "dt": 1673290800, "temp": 27.25, "feels_like": 29.38, "pressure": 1014, @@ -605,7 +609,7 @@ "pop": 0 }, { - "dt": 1673298000, + "dt": 1673294400, "temp": 27.17, "feels_like": 29.24, "pressure": 1015, @@ -628,7 +632,7 @@ "pop": 0 }, { - "dt": 1673301600, + "dt": 1673298000, "temp": 27.07, "feels_like": 29.06, "pressure": 1015, @@ -651,7 +655,7 @@ "pop": 0 }, { - "dt": 1673305200, + "dt": 1673301600, "temp": 26.99, "feels_like": 29.09, "pressure": 1014, @@ -674,7 +678,7 @@ "pop": 0 }, { - "dt": 1673308800, + "dt": 1673305200, "temp": 26.83, "feels_like": 28.8, "pressure": 1014, @@ -697,7 +701,7 @@ "pop": 0 }, { - "dt": 1673312400, + "dt": 1673308800, "temp": 26.68, "feels_like": 28.54, "pressure": 1013, @@ -720,7 +724,7 @@ "pop": 0 }, { - "dt": 1673316000, + "dt": 1673312400, "temp": 26.54, "feels_like": 26.54, "pressure": 1013, @@ -743,7 +747,7 @@ "pop": 0 }, { - "dt": 1673319600, + "dt": 1673316000, "temp": 26.54, "feels_like": 26.54, "pressure": 1012, @@ -766,7 +770,7 @@ "pop": 0 }, { - "dt": 1673323200, + "dt": 1673319600, "temp": 26.43, "feels_like": 26.43, "pressure": 1012, @@ -789,7 +793,7 @@ "pop": 0 }, { - "dt": 1673326800, + "dt": 1673323200, "temp": 26.38, "feels_like": 26.38, "pressure": 1013, @@ -812,7 +816,7 @@ "pop": 0 }, { - "dt": 1673330400, + "dt": 1673326800, "temp": 26.36, "feels_like": 26.36, "pressure": 1013, @@ -835,7 +839,7 @@ "pop": 0 }, { - "dt": 1673334000, + "dt": 1673330400, "temp": 26.45, "feels_like": 26.45, "pressure": 1014, @@ -858,7 +862,7 @@ "pop": 0 }, { - "dt": 1673337600, + "dt": 1673334000, "temp": 26.54, "feels_like": 26.54, "pressure": 1014, @@ -881,7 +885,7 @@ "pop": 0 }, { - "dt": 1673341200, + "dt": 1673337600, "temp": 26.63, "feels_like": 26.63, "pressure": 1014, @@ -904,7 +908,7 @@ "pop": 0 }, { - "dt": 1673344800, + "dt": 1673341200, "temp": 26.62, "feels_like": 26.62, "pressure": 1014, @@ -927,7 +931,7 @@ "pop": 0 }, { - "dt": 1673348400, + "dt": 1673344800, "temp": 26.71, "feels_like": 28.81, "pressure": 1014, @@ -950,7 +954,7 @@ "pop": 0 }, { - "dt": 1673352000, + "dt": 1673348400, "temp": 26.81, "feels_like": 29, "pressure": 1013, @@ -973,7 +977,7 @@ "pop": 0 }, { - "dt": 1673355600, + "dt": 1673352000, "temp": 26.91, "feels_like": 29.19, "pressure": 1012, @@ -996,7 +1000,7 @@ "pop": 0 }, { - "dt": 1673359200, + "dt": 1673355600, "temp": 27.02, "feels_like": 29.32, "pressure": 1012, @@ -1019,7 +1023,7 @@ "pop": 0 }, { - "dt": 1673362800, + "dt": 1673359200, "temp": 27.03, "feels_like": 29.25, "pressure": 1011, @@ -1042,7 +1046,7 @@ "pop": 0 }, { - "dt": 1673366400, + "dt": 1673362800, "temp": 27.12, "feels_like": 29.42, "pressure": 1011, @@ -1065,7 +1069,7 @@ "pop": 0 }, { - "dt": 1673370000, + "dt": 1673366400, "temp": 27.1, "feels_like": 29.29, "pressure": 1012, @@ -1088,7 +1092,7 @@ "pop": 0 }, { - "dt": 1673373600, + "dt": 1673370000, "temp": 27.18, "feels_like": 29.54, "pressure": 1012, diff --git a/tests/mocks/weather_openmeteo_current.json b/tests/mocks/weather_openmeteo_current.json new file mode 100644 index 0000000000..478ee1d161 --- /dev/null +++ b/tests/mocks/weather_openmeteo_current.json @@ -0,0 +1,218 @@ +{ + "latitude": 40.78858, + "longitude": -73.9661, + "generationtime_ms": 0.7585287094116211, + "utc_offset_seconds": -18000, + "timezone": "America/New_York", + "timezone_abbreviation": "GMT-5", + "elevation": 20.0, + "current_units": { "time": "iso8601", "interval": "seconds", "temperature_2m": "°C", "relative_humidity_2m": "%", "weather_code": "wmo code", "wind_speed_10m": "km/h", "wind_direction_10m": "°" }, + "current": { "time": "2026-02-06T16:30", "interval": 900, "temperature_2m": -1.4, "relative_humidity_2m": 60, "weather_code": 3, "wind_speed_10m": 4.8, "wind_direction_10m": 138 }, + "hourly_units": { "time": "iso8601", "temperature_2m": "°C", "precipitation": "mm", "weather_code": "wmo code", "wind_speed_10m": "km/h" }, + "hourly": { + "time": [ + "2026-02-06T00:00", + "2026-02-06T01:00", + "2026-02-06T02:00", + "2026-02-06T03:00", + "2026-02-06T04:00", + "2026-02-06T05:00", + "2026-02-06T06:00", + "2026-02-06T07:00", + "2026-02-06T08:00", + "2026-02-06T09:00", + "2026-02-06T10:00", + "2026-02-06T11:00", + "2026-02-06T12:00", + "2026-02-06T13:00", + "2026-02-06T14:00", + "2026-02-06T15:00", + "2026-02-06T16:00", + "2026-02-06T17:00", + "2026-02-06T18:00", + "2026-02-06T19:00", + "2026-02-06T20:00", + "2026-02-06T21:00", + "2026-02-06T22:00", + "2026-02-06T23:00", + "2026-02-07T00:00", + "2026-02-07T01:00", + "2026-02-07T02:00", + "2026-02-07T03:00", + "2026-02-07T04:00", + "2026-02-07T05:00", + "2026-02-07T06:00", + "2026-02-07T07:00", + "2026-02-07T08:00", + "2026-02-07T09:00", + "2026-02-07T10:00", + "2026-02-07T11:00", + "2026-02-07T12:00", + "2026-02-07T13:00", + "2026-02-07T14:00", + "2026-02-07T15:00", + "2026-02-07T16:00", + "2026-02-07T17:00", + "2026-02-07T18:00", + "2026-02-07T19:00", + "2026-02-07T20:00", + "2026-02-07T21:00", + "2026-02-07T22:00", + "2026-02-07T23:00", + "2026-02-08T00:00", + "2026-02-08T01:00", + "2026-02-08T02:00", + "2026-02-08T03:00", + "2026-02-08T04:00", + "2026-02-08T05:00", + "2026-02-08T06:00", + "2026-02-08T07:00", + "2026-02-08T08:00", + "2026-02-08T09:00", + "2026-02-08T10:00", + "2026-02-08T11:00", + "2026-02-08T12:00", + "2026-02-08T13:00", + "2026-02-08T14:00", + "2026-02-08T15:00", + "2026-02-08T16:00", + "2026-02-08T17:00", + "2026-02-08T18:00", + "2026-02-08T19:00", + "2026-02-08T20:00", + "2026-02-08T21:00", + "2026-02-08T22:00", + "2026-02-08T23:00", + "2026-02-09T00:00", + "2026-02-09T01:00", + "2026-02-09T02:00", + "2026-02-09T03:00", + "2026-02-09T04:00", + "2026-02-09T05:00", + "2026-02-09T06:00", + "2026-02-09T07:00", + "2026-02-09T08:00", + "2026-02-09T09:00", + "2026-02-09T10:00", + "2026-02-09T11:00", + "2026-02-09T12:00", + "2026-02-09T13:00", + "2026-02-09T14:00", + "2026-02-09T15:00", + "2026-02-09T16:00", + "2026-02-09T17:00", + "2026-02-09T18:00", + "2026-02-09T19:00", + "2026-02-09T20:00", + "2026-02-09T21:00", + "2026-02-09T22:00", + "2026-02-09T23:00", + "2026-02-10T00:00", + "2026-02-10T01:00", + "2026-02-10T02:00", + "2026-02-10T03:00", + "2026-02-10T04:00", + "2026-02-10T05:00", + "2026-02-10T06:00", + "2026-02-10T07:00", + "2026-02-10T08:00", + "2026-02-10T09:00", + "2026-02-10T10:00", + "2026-02-10T11:00", + "2026-02-10T12:00", + "2026-02-10T13:00", + "2026-02-10T14:00", + "2026-02-10T15:00", + "2026-02-10T16:00", + "2026-02-10T17:00", + "2026-02-10T18:00", + "2026-02-10T19:00", + "2026-02-10T20:00", + "2026-02-10T21:00", + "2026-02-10T22:00", + "2026-02-10T23:00", + "2026-02-11T00:00", + "2026-02-11T01:00", + "2026-02-11T02:00", + "2026-02-11T03:00", + "2026-02-11T04:00", + "2026-02-11T05:00", + "2026-02-11T06:00", + "2026-02-11T07:00", + "2026-02-11T08:00", + "2026-02-11T09:00", + "2026-02-11T10:00", + "2026-02-11T11:00", + "2026-02-11T12:00", + "2026-02-11T13:00", + "2026-02-11T14:00", + "2026-02-11T15:00", + "2026-02-11T16:00", + "2026-02-11T17:00", + "2026-02-11T18:00", + "2026-02-11T19:00", + "2026-02-11T20:00", + "2026-02-11T21:00", + "2026-02-11T22:00", + "2026-02-11T23:00", + "2026-02-12T00:00", + "2026-02-12T01:00", + "2026-02-12T02:00", + "2026-02-12T03:00", + "2026-02-12T04:00", + "2026-02-12T05:00", + "2026-02-12T06:00", + "2026-02-12T07:00", + "2026-02-12T08:00", + "2026-02-12T09:00", + "2026-02-12T10:00", + "2026-02-12T11:00", + "2026-02-12T12:00", + "2026-02-12T13:00", + "2026-02-12T14:00", + "2026-02-12T15:00", + "2026-02-12T16:00", + "2026-02-12T17:00", + "2026-02-12T18:00", + "2026-02-12T19:00", + "2026-02-12T20:00", + "2026-02-12T21:00", + "2026-02-12T22:00", + "2026-02-12T23:00" + ], + "temperature_2m": [ + -7.1, -7.1, -8.5, -8.8, -9.0, -7.7, -9.2, -8.8, -8.9, -5.9, -3.4, -2.4, -1.3, -0.8, -0.5, -0.2, -0.7, -2.1, -3.2, -3.7, -4.4, -5.3, -6.2, -6.8, -6.5, -6.3, -6.5, -6.3, -5.8, -5.3, -8.5, -11.2, -12.7, -13.6, -13.9, -13.8, -13.5, -13.2, + -12.9, -13.0, -13.2, -14.0, -15.2, -16.1, -16.9, -17.4, -17.5, -17.7, -18.1, -18.5, -18.9, -19.4, -20.0, -20.5, -21.0, -21.5, -20.2, -18.3, -16.4, -14.2, -12.5, -10.8, -9.3, -8.9, -10.0, -10.6, -11.2, -11.8, -12.5, -12.9, -13.4, -13.9, + -14.9, -15.7, -16.6, -17.0, -17.3, -17.5, -17.7, -18.1, -15.5, -12.6, -10.5, -8.6, -7.1, -5.8, -4.9, -4.4, -4.1, -4.9, -7.1, -9.1, -9.7, -6.9, -6.5, -6.4, -6.1, -7.2, -9.6, -10.1, -10.5, -10.8, -10.8, -11.0, -9.1, -6.7, -4.8, -3.1, -2.3, + -1.7, -1.2, -0.8, -0.7, -1.1, -1.8, -1.7, -1.7, -1.9, -4.7, -5.3, -5.3, -5.1, -5.1, -4.8, -5.0, -1.9, -1.2, -0.3, 0.4, 0.6, 0.8, 0.8, 0.6, 0.5, 0.5, 0.6, 0.8, 1.3, 1.9, 2.0, 1.3, 0.1, -0.9, -1.3, -1.6, -1.8, -2.0, -2.3, -2.6, -3.2, -3.8, + -3.9, -3.1, -1.8, -0.8, -0.3, -0.1, 0.0, 0.0, -0.1, -0.5, -1.3, -2.4, -3.3, -4.0, -4.5, -5.0, -5.3 + ], + "precipitation": [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4, 1.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.3, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ], + "weather_code": [ + 0, 0, 0, 0, 2, 0, 3, 3, 3, 0, 3, 3, 3, 1, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 71, 71, 3, 3, 3, 3, 3, 51, 3, 3, 3, 3, 3, 3, 3, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 56, 71, 3, 3, 45, 45, 45, 45, 51, 51, 51, 3, 3, 3, 3, 3, 3, 3, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2 + ], + "wind_speed_10m": [ + 4.3, 9.3, 8.1, 3.3, 11.3, 8.2, 8.8, 7.2, 3.6, 7.2, 5.4, 4.7, 5.0, 1.5, 1.1, 3.9, 4.9, 5.8, 6.5, 5.8, 7.1, 9.3, 8.5, 6.6, 6.6, 6.9, 6.9, 7.5, 8.9, 12.0, 25.8, 27.3, 27.3, 27.0, 28.5, 30.5, 30.8, 30.0, 29.3, 28.3, 27.8, 27.5, 26.3, 24.5, + 25.7, 22.9, 21.9, 19.4, 18.7, 18.4, 21.0, 19.0, 19.1, 18.5, 17.0, 13.8, 13.3, 18.1, 18.6, 19.7, 20.4, 20.5, 20.9, 21.7, 24.2, 22.2, 20.2, 18.8, 15.9, 13.8, 12.8, 11.2, 7.9, 5.9, 4.9, 4.6, 4.3, 3.2, 2.9, 3.0, 5.2, 6.9, 7.5, 6.6, 6.6, 5.2, + 4.1, 5.3, 2.2, 1.9, 2.6, 1.1, 2.2, 4.0, 3.9, 5.2, 4.0, 2.2, 5.0, 4.4, 4.5, 4.7, 4.2, 5.8, 5.8, 7.9, 6.8, 6.0, 5.4, 4.2, 3.4, 3.5, 1.1, 2.8, 5.3, 5.8, 6.1, 4.9, 4.8, 3.3, 3.7, 2.2, 1.0, 1.4, 4.0, 4.4, 3.7, 6.9, 7.6, 7.3, 6.6, 5.5, 4.1, + 3.9, 4.9, 6.0, 7.3, 9.7, 14.1, 17.1, 16.9, 15.1, 14.0, 14.7, 16.4, 17.4, 18.0, 18.1, 17.6, 15.5, 12.9, 11.9, 13.2, 15.8, 18.2, 19.0, 19.2, 19.2, 19.5, 19.7, 19.7, 19.0, 18.0, 17.4, 17.4, 17.6, 17.9, 18.1 + ] + }, + "daily_units": { "time": "iso8601", "weather_code": "wmo code", "temperature_2m_max": "°C", "temperature_2m_min": "°C", "sunrise": "iso8601", "sunset": "iso8601", "precipitation_sum": "mm" }, + "daily": { + "time": ["2026-02-06", "2026-02-07", "2026-02-08", "2026-02-09", "2026-02-10", "2026-02-11", "2026-02-12"], + "weather_code": [3, 71, 3, 3, 3, 71, 3], + "temperature_2m_max": [-0.2, -5.3, -8.9, -4.1, -0.7, 2.0, 0.0], + "temperature_2m_min": [-9.2, -17.7, -21.5, -18.1, -11.0, -5.3, -5.3], + "sunrise": ["2026-02-06T07:00", "2026-02-07T06:59", "2026-02-08T06:58", "2026-02-09T06:56", "2026-02-10T06:55", "2026-02-11T06:54", "2026-02-12T06:53"], + "sunset": ["2026-02-06T17:19", "2026-02-07T17:20", "2026-02-08T17:21", "2026-02-09T17:23", "2026-02-10T17:24", "2026-02-11T17:25", "2026-02-12T17:26"], + "precipitation_sum": [0.0, 0.4, 0.0, 0.0, 0.0, 2.6, 0.0] + } +} diff --git a/tests/mocks/weather_openmeteo_current_weather.json b/tests/mocks/weather_openmeteo_current_weather.json new file mode 100644 index 0000000000..ba5f183161 --- /dev/null +++ b/tests/mocks/weather_openmeteo_current_weather.json @@ -0,0 +1,84 @@ +{ + "latitude": 48.14, + "longitude": 11.58, + "generationtime_ms": 0.3949403762817383, + "utc_offset_seconds": 3600, + "timezone": "Europe/Berlin", + "timezone_abbreviation": "GMT+1", + "elevation": 524.0, + "current_weather_units": { + "time": "unixtime", + "interval": "seconds", + "temperature": "°C", + "windspeed": "km/h", + "winddirection": "°", + "is_day": "", + "weathercode": "wmo code" + }, + "current_weather": { + "time": 1770477300, + "interval": 900, + "temperature": 8.5, + "windspeed": 4.7, + "winddirection": 9, + "is_day": 1, + "weathercode": 2 + }, + "hourly_units": { + "time": "unixtime", + "temperature_2m": "°C", + "windspeed_10m": "km/h", + "winddirection_10m": "°", + "relativehumidity_2m": "%" + }, + "hourly": { + "time": [ + 1770418800, 1770422400, 1770426000, 1770429600, 1770433200, 1770436800, 1770440400, 1770444000, 1770447600, 1770451200, 1770454800, 1770458400, 1770462000, 1770465600, 1770469200, 1770472800, 1770476400, 1770480000, 1770483600, + 1770487200, 1770490800, 1770494400, 1770498000, 1770501600, 1770505200, 1770508800, 1770512400, 1770516000, 1770519600, 1770523200, 1770526800, 1770530400, 1770534000, 1770537600, 1770541200, 1770544800, 1770548400, 1770552000, + 1770555600, 1770559200, 1770562800, 1770566400, 1770570000, 1770573600, 1770577200, 1770580800, 1770584400, 1770588000, 1770591600, 1770595200, 1770598800, 1770602400, 1770606000, 1770609600, 1770613200, 1770616800, 1770620400, + 1770624000, 1770627600, 1770631200, 1770634800, 1770638400, 1770642000, 1770645600, 1770649200, 1770652800, 1770656400, 1770660000, 1770663600, 1770667200, 1770670800, 1770674400, 1770678000, 1770681600, 1770685200, 1770688800, + 1770692400, 1770696000, 1770699600, 1770703200, 1770706800, 1770710400, 1770714000, 1770717600, 1770721200, 1770724800, 1770728400, 1770732000, 1770735600, 1770739200, 1770742800, 1770746400, 1770750000, 1770753600, 1770757200, + 1770760800, 1770764400, 1770768000, 1770771600, 1770775200, 1770778800, 1770782400, 1770786000, 1770789600, 1770793200, 1770796800, 1770800400, 1770804000, 1770807600, 1770811200, 1770814800, 1770818400, 1770822000, 1770825600, + 1770829200, 1770832800, 1770836400, 1770840000, 1770843600, 1770847200, 1770850800, 1770854400, 1770858000, 1770861600, 1770865200, 1770868800, 1770872400, 1770876000, 1770879600, 1770883200, 1770886800, 1770890400, 1770894000, + 1770897600, 1770901200, 1770904800, 1770908400, 1770912000, 1770915600, 1770919200, 1770922800, 1770926400, 1770930000, 1770933600, 1770937200, 1770940800, 1770944400, 1770948000, 1770951600, 1770955200, 1770958800, 1770962400, + 1770966000, 1770969600, 1770973200, 1770976800, 1770980400, 1770984000, 1770987600, 1770991200, 1770994800, 1770998400, 1771002000, 1771005600, 1771009200, 1771012800, 1771016400, 1771020000 + ], + "temperature_2m": [ + 6.4, 6.6, 6.3, 5.8, 5.7, 5.1, 5.0, 5.5, 5.2, 5.3, 6.2, 7.0, 7.9, 9.1, 9.5, 9.0, 8.6, 8.2, 7.2, 6.4, 5.5, 5.1, 4.7, 4.9, 4.5, 4.5, 4.5, 4.7, 3.8, 3.1, 3.2, 3.0, 3.6, 3.8, 3.5, 4.3, 5.2, 6.2, 6.6, 6.8, 6.4, 5.5, 4.7, 4.7, 4.3, 4.2, 4.1, + 3.9, 3.6, 3.4, 3.2, 3.0, 2.9, 2.8, 2.7, 2.6, 2.5, 2.8, 3.1, 3.7, 4.3, 4.6, 4.7, 4.7, 4.4, 4.0, 3.5, 3.0, 2.5, 1.9, 1.2, 0.7, 0.3, 0.0, -0.1, -0.4, -0.8, -1.1, -1.3, -1.2, -1.2, -1.0, 0.2, 1.9, 3.3, 5.0, 6.1, 6.8, 7.0, 6.4, 5.2, 4.2, 3.6, + 3.1, 2.8, 2.5, 2.2, 2.2, 2.3, 2.6, 2.9, 3.0, 3.0, 3.4, 4.7, 6.3, 7.7, 8.5, 9.0, 9.3, 9.2, 8.9, 8.6, 8.3, 8.1, 7.9, 7.6, 7.4, 7.2, 7.1, 7.0, 6.9, 6.7, 6.4, 6.2, 5.6, 5.2, 4.9, 6.1, 6.4, 6.6, 6.9, 7.1, 7.2, 7.2, 7.0, 6.8, 6.4, 5.9, 5.5, + 5.4, 5.3, 5.2, 5.0, 4.8, 4.6, 4.5, 4.5, 4.6, 4.6, 4.6, 4.8, 5.2, 5.8, 6.4, 7.2, 8.1, 8.6, 8.3, 7.6, 7.0, 6.5, 6.2, 5.8, 5.5, 5.2, 5.0, 4.9 + ], + "windspeed_10m": [ + 9.4, 9.5, 9.2, 9.1, 7.9, 6.6, 6.8, 7.6, 6.7, 6.0, 5.9, 5.9, 6.6, 4.5, 7.9, 7.7, 5.4, 4.2, 3.3, 1.4, 2.6, 1.6, 2.2, 1.5, 0.8, 2.3, 2.3, 1.3, 2.5, 1.8, 1.6, 1.1, 1.8, 2.9, 5.6, 9.1, 9.0, 7.8, 9.0, 9.8, 9.7, 9.8, 10.4, 9.1, 7.9, 8.2, 7.6, + 6.0, 6.2, 5.3, 5.0, 5.5, 6.2, 5.9, 6.2, 6.5, 5.9, 5.4, 6.2, 5.9, 6.7, 6.5, 6.2, 6.2, 5.8, 5.5, 4.7, 4.2, 3.2, 3.4, 3.2, 2.7, 2.9, 3.7, 2.9, 3.4, 3.2, 3.6, 3.1, 3.6, 3.8, 4.6, 4.8, 4.3, 5.5, 5.3, 5.2, 4.9, 4.4, 3.2, 2.2, 2.0, 2.2, 3.0, + 3.4, 3.6, 4.0, 4.9, 5.2, 5.2, 5.2, 5.5, 5.8, 6.5, 9.0, 13.2, 16.9, 19.3, 20.8, 21.7, 22.3, 22.1, 22.0, 21.5, 21.2, 20.9, 20.2, 20.0, 19.3, 18.9, 18.2, 17.4, 16.4, 15.3, 14.2, 12.8, 11.7, 11.2, 21.7, 21.5, 21.5, 22.1, 23.0, 23.7, 23.8, + 23.6, 23.0, 22.0, 20.5, 19.5, 19.2, 19.3, 19.3, 19.5, 19.5, 20.0, 20.9, 21.9, 23.2, 24.7, 26.1, 26.9, 26.3, 25.4, 24.6, 25.0, 25.6, 26.1, 25.5, 24.4, 22.9, 20.5, 17.9, 15.9, 14.8, 14.3, 14.2, 14.8 + ], + "winddirection_10m": [ + 247, 241, 244, 252, 240, 229, 245, 251, 234, 245, 256, 256, 261, 284, 300, 332, 356, 59, 84, 90, 164, 207, 189, 284, 243, 198, 321, 304, 172, 180, 207, 180, 79, 30, 45, 72, 74, 68, 74, 73, 75, 54, 56, 56, 66, 67, 82, 57, 69, 62, 60, 58, + 69, 79, 83, 87, 101, 98, 97, 95, 88, 89, 97, 97, 97, 113, 122, 121, 117, 108, 117, 113, 120, 119, 120, 122, 117, 127, 135, 135, 131, 129, 117, 95, 79, 62, 56, 54, 55, 63, 90, 135, 171, 194, 198, 180, 153, 144, 146, 155, 164, 169, 173, + 186, 209, 225, 232, 237, 242, 246, 247, 248, 249, 249, 249, 249, 248, 247, 246, 246, 245, 246, 244, 240, 240, 240, 242, 245, 249, 249, 249, 251, 253, 253, 252, 251, 250, 249, 246, 245, 244, 243, 243, 242, 242, 242, 243, 245, 246, 247, + 247, 247, 247, 245, 245, 247, 250, 252, 254, 254, 254, 252, 248, 245, 244, 245, 246, 247 + ], + "relativehumidity_2m": [ + 83, 81, 81, 84, 83, 85, 86, 88, 89, 89, 85, 82, 77, 65, 62, 68, 71, 73, 81, 87, 91, 91, 93, 93, 94, 94, 94, 94, 99, 97, 94, 93, 94, 95, 96, 91, 86, 79, 77, 77, 76, 81, 85, 82, 89, 88, 87, 87, 89, 90, 90, 91, 92, 93, 94, 94, 95, 93, 89, + 83, 80, 75, 75, 75, 76, 79, 82, 83, 86, 90, 93, 93, 93, 95, 95, 95, 95, 97, 98, 99, 99, 98, 92, 84, 79, 73, 69, 67, 67, 69, 73, 77, 80, 83, 85, 87, 88, 87, 83, 76, 73, 76, 81, 85, 84, 82, 79, 76, 74, 72, 72, 72, 73, 73, 73, 74, 75, 77, + 78, 77, 75, 74, 73, 73, 74, 78, 84, 87, 81, 80, 78, 77, 77, 77, 77, 77, 78, 80, 82, 84, 85, 86, 86, 86, 87, 87, 87, 88, 88, 88, 88, 86, 82, 78, 73, 69, 65, 63, 64, 68, 72, 76, 81, 84, 85, 85, 85, 85 + ] + }, + "daily_units": { + "time": "unixtime", + "temperature_2m_max": "°C", + "temperature_2m_min": "°C", + "sunrise": "unixtime", + "sunset": "unixtime" + }, + "daily": { + "time": [1770418800, 1770505200, 1770591600, 1770678000, 1770764400, 1770850800, 1770937200], + "temperature_2m_max": [9.5, 6.8, 4.7, 7.0, 9.3, 7.2, 8.6], + "temperature_2m_min": [4.7, 3.0, 0.7, -1.3, 2.2, 4.9, 4.5], + "sunrise": [1770445978, 1770532288, 1770618596, 1770704904, 1770791211, 1770877515, 1770963818], + "sunset": [1770481348, 1770567844, 1770654340, 1770740836, 1770827331, 1770913826, 1771000321] + } +} diff --git a/tests/mocks/weather_owm_onecall.json b/tests/mocks/weather_owm_onecall.json new file mode 100644 index 0000000000..0696e347cb --- /dev/null +++ b/tests/mocks/weather_owm_onecall.json @@ -0,0 +1,970 @@ +{ + "lat": 40.7767, + "lon": -73.9713, + "timezone": "America/New_York", + "timezone_offset": -18000, + "current": { + "dt": 1770414297, + "sunrise": 1770379257, + "sunset": 1770416341, + "temp": -0.27, + "feels_like": -3.9, + "pressure": 1004, + "humidity": 54, + "dew_point": -7.54, + "uvi": 0, + "clouds": 75, + "visibility": 10000, + "wind_speed": 3.09, + "wind_deg": 220, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }] + }, + "hourly": [ + { + "dt": 1770411600, + "temp": -0.66, + "feels_like": -3.52, + "pressure": 1004, + "humidity": 61, + "dew_point": -6.5, + "uvi": 0.18, + "clouds": 80, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 187, + "wind_gust": 3.73, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770415200, + "temp": -0.27, + "feels_like": -2.6, + "pressure": 1004, + "humidity": 54, + "dew_point": -7.54, + "uvi": 0, + "clouds": 75, + "visibility": 10000, + "wind_speed": 1.87, + "wind_deg": 169, + "wind_gust": 3.26, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770418800, + "temp": -1.03, + "feels_like": -3.4, + "pressure": 1004, + "humidity": 62, + "dew_point": -6.67, + "uvi": 0, + "clouds": 80, + "visibility": 10000, + "wind_speed": 1.81, + "wind_deg": 190, + "wind_gust": 3.93, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770422400, + "temp": -1.54, + "feels_like": -5.39, + "pressure": 1004, + "humidity": 71, + "dew_point": -5.59, + "uvi": 0, + "clouds": 85, + "wind_speed": 3.04, + "wind_deg": 232, + "wind_gust": 6.25, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 0.2, + "snow": { "1h": 0.13 } + }, + { + "dt": 1770426000, + "temp": -2.25, + "feels_like": -5.2, + "pressure": 1004, + "humidity": 80, + "dew_point": -4.89, + "uvi": 0, + "clouds": 90, + "visibility": 235, + "wind_speed": 2.09, + "wind_deg": 224, + "wind_gust": 6.04, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.18 } + }, + { + "dt": 1770429600, + "temp": -2.79, + "feels_like": -6.29, + "pressure": 1003, + "humidity": 89, + "dew_point": -4.17, + "uvi": 0, + "clouds": 95, + "visibility": 177, + "wind_speed": 2.47, + "wind_deg": 217, + "wind_gust": 6.99, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.19 } + }, + { + "dt": 1770433200, + "temp": -3.46, + "feels_like": -7.71, + "pressure": 1002, + "humidity": 96, + "dew_point": -4.21, + "uvi": 0, + "clouds": 100, + "visibility": 501, + "wind_speed": 3.05, + "wind_deg": 236, + "wind_gust": 7.82, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.19 } + }, + { + "dt": 1770436800, + "temp": -3.88, + "feels_like": -7.67, + "pressure": 1001, + "humidity": 97, + "dew_point": -4.47, + "uvi": 0, + "clouds": 100, + "visibility": 424, + "wind_speed": 2.54, + "wind_deg": 234, + "wind_gust": 7.49, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0.8 + }, + { + "dt": 1770440400, + "temp": -3.78, + "feels_like": -7.68, + "pressure": 1001, + "humidity": 96, + "dew_point": -4.57, + "uvi": 0, + "clouds": 100, + "visibility": 2576, + "wind_speed": 2.66, + "wind_deg": 231, + "wind_gust": 7.51, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.14 } + }, + { + "dt": 1770444000, + "temp": -4.1, + "feels_like": -8.05, + "pressure": 1000, + "humidity": 96, + "dew_point": -4.92, + "uvi": 0, + "clouds": 100, + "visibility": 305, + "wind_speed": 2.65, + "wind_deg": 237, + "wind_gust": 7.6, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0.8 + }, + { + "dt": 1770447600, + "temp": -4.12, + "feels_like": -8.44, + "pressure": 1000, + "humidity": 95, + "dew_point": -4.97, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.99, + "wind_deg": 247, + "wind_gust": 7.23, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770451200, + "temp": -4.9, + "feels_like": -9.33, + "pressure": 999, + "humidity": 95, + "dew_point": -5.82, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.95, + "wind_deg": 256, + "wind_gust": 7.85, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770454800, + "temp": -4.84, + "feels_like": -9.36, + "pressure": 999, + "humidity": 94, + "dew_point": -5.93, + "uvi": 0, + "clouds": 100, + "visibility": 4481, + "wind_speed": 3.04, + "wind_deg": 273, + "wind_gust": 10.32, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770458400, + "temp": -5.46, + "feels_like": -12.46, + "pressure": 1000, + "humidity": 85, + "dew_point": -7.96, + "uvi": 0, + "clouds": 100, + "visibility": 9905, + "wind_speed": 7.66, + "wind_deg": 316, + "wind_gust": 11.92, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770462000, + "temp": -9.55, + "feels_like": -16.55, + "pressure": 1001, + "humidity": 76, + "dew_point": -13.6, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.25, + "wind_deg": 315, + "wind_gust": 15.03, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770465600, + "temp": -12.37, + "feels_like": -19.37, + "pressure": 1002, + "humidity": 76, + "dew_point": -16.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.55, + "wind_deg": 309, + "wind_gust": 15.72, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770469200, + "temp": -14.13, + "feels_like": -21.13, + "pressure": 1003, + "humidity": 76, + "dew_point": -18.65, + "uvi": 0.27, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.44, + "wind_deg": 308, + "wind_gust": 16.05, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770472800, + "temp": -13.41, + "feels_like": -20.41, + "pressure": 1004, + "humidity": 76, + "dew_point": -17.82, + "uvi": 0.72, + "clouds": 56, + "visibility": 10000, + "wind_speed": 8.4, + "wind_deg": 311, + "wind_gust": 16, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770476400, + "temp": -12.76, + "feels_like": -19.76, + "pressure": 1004, + "humidity": 78, + "dew_point": -16.79, + "uvi": 1.2, + "clouds": 52, + "visibility": 10000, + "wind_speed": 8.67, + "wind_deg": 317, + "wind_gust": 15.12, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770480000, + "temp": -12.33, + "feels_like": -19.33, + "pressure": 1005, + "humidity": 83, + "dew_point": -15.61, + "uvi": 1.56, + "clouds": 64, + "visibility": 3083, + "wind_speed": 8.8, + "wind_deg": 321, + "wind_gust": 15.19, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770483600, + "temp": -11.87, + "feels_like": -18.87, + "pressure": 1004, + "humidity": 82, + "dew_point": -15.28, + "uvi": 1.56, + "clouds": 71, + "visibility": 8917, + "wind_speed": 8.88, + "wind_deg": 322, + "wind_gust": 15.55, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770487200, + "temp": -11.69, + "feels_like": -18.69, + "pressure": 1005, + "humidity": 79, + "dew_point": -15.5, + "uvi": 1.57, + "clouds": 76, + "visibility": 10000, + "wind_speed": 9.46, + "wind_deg": 324, + "wind_gust": 16.31, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770490800, + "temp": -11.62, + "feels_like": -18.62, + "pressure": 1005, + "humidity": 77, + "dew_point": -15.73, + "uvi": 1.11, + "clouds": 100, + "visibility": 10000, + "wind_speed": 9.8, + "wind_deg": 327, + "wind_gust": 16.18, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770494400, + "temp": -12, + "feels_like": -19, + "pressure": 1006, + "humidity": 75, + "dew_point": -16.48, + "uvi": 0.59, + "clouds": 100, + "visibility": 10000, + "wind_speed": 9.97, + "wind_deg": 328, + "wind_gust": 16.89, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770498000, + "temp": -12.71, + "feels_like": -19.71, + "pressure": 1007, + "humidity": 74, + "dew_point": -17.39, + "uvi": 0.19, + "clouds": 100, + "visibility": 10000, + "wind_speed": 10.12, + "wind_deg": 328, + "wind_gust": 17.9, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770501600, + "temp": -13.43, + "feels_like": -20.43, + "pressure": 1009, + "humidity": 72, + "dew_point": -18.44, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 10.09, + "wind_deg": 329, + "wind_gust": 18.24, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770505200, + "temp": -14.05, + "feels_like": -21.05, + "pressure": 1011, + "humidity": 72, + "dew_point": -19.28, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 10.11, + "wind_deg": 329, + "wind_gust": 18.4, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770508800, + "temp": -14.31, + "feels_like": -21.31, + "pressure": 1013, + "humidity": 72, + "dew_point": -19.61, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 10.18, + "wind_deg": 328, + "wind_gust": 18.77, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770512400, + "temp": -14.29, + "feels_like": -21.29, + "pressure": 1014, + "humidity": 72, + "dew_point": -19.51, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 9.7, + "wind_deg": 330, + "wind_gust": 18.29, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770516000, + "temp": -14.14, + "feels_like": -21.14, + "pressure": 1015, + "humidity": 72, + "dew_point": -19.28, + "uvi": 0, + "clouds": 98, + "visibility": 10000, + "wind_speed": 9.38, + "wind_deg": 330, + "wind_gust": 17.25, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770519600, + "temp": -14.08, + "feels_like": -21.08, + "pressure": 1016, + "humidity": 73, + "dew_point": -19.05, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.71, + "wind_deg": 329, + "wind_gust": 16.58, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770523200, + "temp": -14.19, + "feels_like": -21.19, + "pressure": 1016, + "humidity": 74, + "dew_point": -19.05, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.24, + "wind_deg": 328, + "wind_gust": 15.71, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770526800, + "temp": -14.38, + "feels_like": -21.38, + "pressure": 1017, + "humidity": 74, + "dew_point": -19.34, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.08, + "wind_deg": 326, + "wind_gust": 15.77, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770530400, + "temp": -14.74, + "feels_like": -21.74, + "pressure": 1018, + "humidity": 74, + "dew_point": -19.74, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 7.81, + "wind_deg": 324, + "wind_gust": 15.4, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770534000, + "temp": -15.13, + "feels_like": -22.13, + "pressure": 1019, + "humidity": 73, + "dew_point": -20.25, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 7.57, + "wind_deg": 325, + "wind_gust": 15.39, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770537600, + "temp": -15.57, + "feels_like": -22.57, + "pressure": 1019, + "humidity": 73, + "dew_point": -20.69, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 7.36, + "wind_deg": 323, + "wind_gust": 15.29, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770541200, + "temp": -15.98, + "feels_like": -22.98, + "pressure": 1019, + "humidity": 73, + "dew_point": -21.2, + "uvi": 0, + "clouds": 88, + "visibility": 10000, + "wind_speed": 7.37, + "wind_deg": 321, + "wind_gust": 15.7, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770544800, + "temp": -16.36, + "feels_like": -23.36, + "pressure": 1020, + "humidity": 73, + "dew_point": -21.64, + "uvi": 0, + "clouds": 69, + "visibility": 10000, + "wind_speed": 7.62, + "wind_deg": 322, + "wind_gust": 16.29, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770548400, + "temp": -16.63, + "feels_like": -23.63, + "pressure": 1021, + "humidity": 74, + "dew_point": -21.86, + "uvi": 0, + "clouds": 57, + "visibility": 10000, + "wind_speed": 7.52, + "wind_deg": 323, + "wind_gust": 16.46, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770552000, + "temp": -16.84, + "feels_like": -23.84, + "pressure": 1022, + "humidity": 74, + "dew_point": -22.06, + "uvi": 0, + "clouds": 48, + "visibility": 10000, + "wind_speed": 7.59, + "wind_deg": 324, + "wind_gust": 16.2, + "weather": [{ "id": 802, "main": "Clouds", "description": "scattered clouds", "icon": "03d" }], + "pop": 0 + }, + { + "dt": 1770555600, + "temp": -16.57, + "feels_like": -23.57, + "pressure": 1023, + "humidity": 74, + "dew_point": -21.63, + "uvi": 0.3, + "clouds": 2, + "visibility": 10000, + "wind_speed": 7.27, + "wind_deg": 325, + "wind_gust": 14.68, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770559200, + "temp": -15.7, + "feels_like": -22.7, + "pressure": 1023, + "humidity": 76, + "dew_point": -20.43, + "uvi": 0.77, + "clouds": 4, + "visibility": 10000, + "wind_speed": 7.26, + "wind_deg": 324, + "wind_gust": 13.65, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770562800, + "temp": -14.48, + "feels_like": -21.48, + "pressure": 1023, + "humidity": 77, + "dew_point": -18.94, + "uvi": 1.42, + "clouds": 5, + "visibility": 10000, + "wind_speed": 6.7, + "wind_deg": 324, + "wind_gust": 12.19, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770566400, + "temp": -13.34, + "feels_like": -20.34, + "pressure": 1023, + "humidity": 73, + "dew_point": -18.23, + "uvi": 1.98, + "clouds": 5, + "visibility": 10000, + "wind_speed": 6.59, + "wind_deg": 327, + "wind_gust": 10.06, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770570000, + "temp": -11.94, + "feels_like": -18.94, + "pressure": 1022, + "humidity": 74, + "dew_point": -16.63, + "uvi": 2.19, + "clouds": 6, + "visibility": 10000, + "wind_speed": 6.3, + "wind_deg": 325, + "wind_gust": 9.29, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770573600, + "temp": -10.51, + "feels_like": -17.51, + "pressure": 1022, + "humidity": 75, + "dew_point": -14.88, + "uvi": 1.95, + "clouds": 7, + "visibility": 10000, + "wind_speed": 5.98, + "wind_deg": 321, + "wind_gust": 8.89, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770577200, + "temp": -9.42, + "feels_like": -16.42, + "pressure": 1022, + "humidity": 75, + "dew_point": -13.63, + "uvi": 1.39, + "clouds": 72, + "visibility": 10000, + "wind_speed": 5.92, + "wind_deg": 317, + "wind_gust": 8.77, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770580800, + "temp": -8.96, + "feels_like": -15.96, + "pressure": 1023, + "humidity": 79, + "dew_point": -12.4, + "uvi": 0.73, + "clouds": 80, + "visibility": 10000, + "wind_speed": 6.03, + "wind_deg": 312, + "wind_gust": 10.13, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + } + ], + "daily": [ + { + "dt": 1770397200, + "sunrise": 1770379257, + "sunset": 1770416341, + "moonrise": 1770435960, + "moonset": 1770386880, + "moon_phase": 0.66, + "summary": "Expect a day of partly cloudy with snow", + "temp": { "day": -2.5, "min": -11.86, "max": -0.27, "night": -3.88, "eve": -1.03, "morn": -10.39 }, + "feels_like": { "day": -2.5, "night": -7.67, "eve": -3.4, "morn": -14.33 }, + "pressure": 1006, + "humidity": 88, + "dew_point": -4.3, + "wind_speed": 3.05, + "wind_deg": 236, + "wind_gust": 7.82, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13d" }], + "clouds": 95, + "pop": 1, + "snow": 0.69, + "uvi": 2.22 + }, + { + "dt": 1770483600, + "sunrise": 1770465590, + "sunset": 1770502816, + "moonrise": 1770526200, + "moonset": 1770474600, + "moon_phase": 0.69, + "summary": "There will be snow until morning, then partly cloudy", + "temp": { "day": -11.87, "min": -14.31, "max": -3.78, "night": -14.19, "eve": -14.05, "morn": -9.55 }, + "feels_like": { "day": -18.87, "night": -21.19, "eve": -21.05, "morn": -16.55 }, + "pressure": 1004, + "humidity": 82, + "dew_point": -15.28, + "wind_speed": 10.18, + "wind_deg": 328, + "wind_gust": 18.77, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13d" }], + "clouds": 71, + "pop": 1, + "snow": 0.14, + "uvi": 1.57 + }, + { + "dt": 1770570000, + "sunrise": 1770551923, + "sunset": 1770589291, + "moonrise": 0, + "moonset": 1770562440, + "moon_phase": 0.72, + "summary": "Expect a day of partly cloudy with clear spells", + "temp": { "day": -11.94, "min": -16.84, "max": -8.96, "night": -13.75, "eve": -11.11, "morn": -16.63 }, + "feels_like": { "day": -18.94, "night": -20.33, "eve": -18.11, "morn": -23.63 }, + "pressure": 1022, + "humidity": 74, + "dew_point": -16.63, + "wind_speed": 8.08, + "wind_deg": 326, + "wind_gust": 16.46, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "clouds": 6, + "pop": 0, + "uvi": 2.19 + }, + { + "dt": 1770656400, + "sunrise": 1770638253, + "sunset": 1770675765, + "moonrise": 1770616380, + "moonset": 1770650520, + "moon_phase": 0.75, + "summary": "The day will start with clear sky through the late morning hours, transitioning to partly cloudy", + "temp": { "day": -6.9, "min": -17.11, "max": -3.39, "night": -5.77, "eve": -7.87, "morn": -16.94 }, + "feels_like": { "day": -10.1, "night": -5.77, "eve": -7.87, "morn": -16.94 }, + "pressure": 1024, + "humidity": 78, + "dew_point": -10.38, + "wind_speed": 2.5, + "wind_deg": 319, + "wind_gust": 7.03, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 83, + "pop": 0, + "uvi": 2.7 + }, + { + "dt": 1770742800, + "sunrise": 1770724583, + "sunset": 1770762240, + "moonrise": 1770706560, + "moonset": 1770739020, + "moon_phase": 0.79, + "summary": "There will be partly cloudy today", + "temp": { "day": -1.46, "min": -10, "max": -0.51, "night": -3.8, "eve": -1.57, "morn": -10 }, + "feels_like": { "day": -1.46, "night": -6.36, "eve": -3.98, "morn": -13.81 }, + "pressure": 1020, + "humidity": 94, + "dew_point": -2.47, + "wind_speed": 1.83, + "wind_deg": 2, + "wind_gust": 2.92, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 56, + "pop": 0, + "uvi": 3.1 + }, + { + "dt": 1770829200, + "sunrise": 1770810911, + "sunset": 1770848714, + "moonrise": 1770796620, + "moonset": 1770827880, + "moon_phase": 0.82, + "summary": "The day will start with partly cloudy with snow through the late morning hours, transitioning to partly cloudy with rain", + "temp": { "day": 0.7, "min": -4.02, "max": 2.06, "night": -0.6, "eve": 2.06, "morn": -0.03 }, + "feels_like": { "day": 0.7, "night": -5, "eve": -2, "morn": -3.1 }, + "pressure": 1009, + "humidity": 100, + "dew_point": 0.64, + "wind_speed": 4.4, + "wind_deg": 311, + "wind_gust": 11.56, + "weather": [{ "id": 616, "main": "Snow", "description": "rain and snow", "icon": "13d" }], + "clouds": 100, + "pop": 1, + "rain": 4.38, + "snow": 2.17, + "uvi": 4 + }, + { + "dt": 1770915600, + "sunrise": 1770897237, + "sunset": 1770935188, + "moonrise": 1770886440, + "moonset": 1770917220, + "moon_phase": 0.85, + "summary": "There will be partly cloudy today", + "temp": { "day": 0.2, "min": -4.63, "max": 0.2, "night": -4.63, "eve": -2.9, "morn": -3.67 }, + "feels_like": { "day": -4.8, "night": -10.67, "eve": -8.49, "morn": -8.22 }, + "pressure": 1012, + "humidity": 81, + "dew_point": -2.81, + "wind_speed": 5.52, + "wind_deg": 301, + "wind_gust": 12.97, + "weather": [{ "id": 802, "main": "Clouds", "description": "scattered clouds", "icon": "03d" }], + "clouds": 50, + "pop": 0, + "uvi": 4 + }, + { + "dt": 1771002000, + "sunrise": 1770983562, + "sunset": 1771021662, + "moonrise": 1770975780, + "moonset": 1771007160, + "moon_phase": 0.88, + "summary": "Expect a day of partly cloudy with clear spells", + "temp": { "day": 0.38, "min": -6.39, "max": 0.95, "night": -1.17, "eve": -0.91, "morn": -6.39 }, + "feels_like": { "day": -3.92, "night": -6.3, "eve": -5.42, "morn": -12.89 }, + "pressure": 1017, + "humidity": 80, + "dew_point": -2.71, + "wind_speed": 5.27, + "wind_deg": 298, + "wind_gust": 14.69, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 74, + "pop": 0, + "uvi": 4 + } + ] +} diff --git a/tests/mocks/weather_pirateweather.json b/tests/mocks/weather_pirateweather.json new file mode 100644 index 0000000000..75a13969b5 --- /dev/null +++ b/tests/mocks/weather_pirateweather.json @@ -0,0 +1,1665 @@ +{ + "latitude": 40.7128, + "longitude": -74.006, + "timezone": "America/New_York", + "offset": -5.0, + "elevation": 19, + "currently": { + "time": 1770414300, + "summary": "Overcast", + "icon": "cloudy", + "nearestStormDistance": 115.95, + "nearestStormBearing": 233, + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipType": "none", + "temperature": -0.26, + "apparentTemperature": -4.77, + "dewPoint": -7.89, + "humidity": 0.56, + "pressure": 1004.92, + "windSpeed": 2.32, + "windGust": 3.2, + "windBearing": 166, + "cloudCover": 0.97, + "uvIndex": 0.54, + "visibility": 16.09, + "ozone": 401.41 + }, + "minutely": { + "summary": "Overcast for the hour.", + "icon": "cloudy", + "data": [ + { "time": 1770414300, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414360, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414420, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414480, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414540, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414600, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414660, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414720, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414780, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414840, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414900, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414960, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415020, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415080, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415140, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415200, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415260, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415320, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415380, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415440, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415500, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415560, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415620, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415680, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415740, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415800, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415860, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415920, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415980, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416040, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416100, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416160, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416220, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416280, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416340, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416400, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416460, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416520, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416580, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416640, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416700, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416760, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416820, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416880, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416940, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417000, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417060, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417120, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417180, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417240, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417300, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417360, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417420, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417480, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417540, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417600, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417660, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417720, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417780, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417840, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417900, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" } + ] + }, + "hourly": { + "summary": "Hazy tonight and windy starting tomorrow morning.", + "icon": "fog", + "data": [ + { + "time": 1770411600, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.19, + "apparentTemperature": -6.47, + "dewPoint": -6.77, + "humidity": 0.7, + "pressure": 1005.22, + "windSpeed": 3.6, + "windGust": 4.7, + "windBearing": 200, + "cloudCover": 0.77, + "uvIndex": 1.12, + "visibility": 16.09, + "ozone": 402.15, + "nearestStormDistance": 108.83, + "nearestStormBearing": 258 + }, + { + "time": 1770415200, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.11, + "apparentTemperature": -6.3, + "dewPoint": -6.64, + "humidity": 0.71, + "pressure": 1004.82, + "windSpeed": 3.6, + "windGust": 4.77, + "windBearing": 207, + "cloudCover": 0.8, + "uvIndex": 0.35, + "visibility": 14.72, + "ozone": 401.17, + "nearestStormDistance": 118.33, + "nearestStormBearing": 233 + }, + { + "time": 1770418800, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.02, + "apparentTemperature": -6.13, + "dewPoint": -6.5, + "humidity": 0.71, + "pressure": 1004.19, + "windSpeed": 3.6, + "windGust": 4.83, + "windBearing": 213, + "cloudCover": 0.83, + "uvIndex": 0.01, + "visibility": 13.18, + "ozone": 403.38, + "nearestStormDistance": 63.25, + "nearestStormBearing": 270 + }, + { + "time": 1770422400, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.94, + "apparentTemperature": -5.96, + "dewPoint": -6.37, + "humidity": 0.72, + "pressure": 1004.33, + "windSpeed": 3.6, + "windGust": 4.9, + "windBearing": 220, + "cloudCover": 0.86, + "uvIndex": 0.0, + "visibility": 11.65, + "ozone": 406.37, + "nearestStormDistance": 34.89, + "nearestStormBearing": 225 + }, + { + "time": 1770426000, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.22, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.87, + "apparentTemperature": -6.11, + "dewPoint": -6.26, + "humidity": 0.73, + "pressure": 1003.94, + "windSpeed": 3.6, + "windGust": 4.93, + "windBearing": 223, + "cloudCover": 0.87, + "uvIndex": 0.0, + "visibility": 8.58, + "ozone": 408.33, + "nearestStormDistance": 21.08, + "nearestStormBearing": 270 + }, + { + "time": 1770429600, + "summary": "Overcast", + "icon": "cloudy", + "precipIntensity": 0.0, + "precipProbability": 0.27, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.79, + "apparentTemperature": -6.25, + "dewPoint": -6.15, + "humidity": 0.73, + "pressure": 1003.89, + "windSpeed": 3.6, + "windGust": 4.97, + "windBearing": 227, + "cloudCover": 0.88, + "uvIndex": 0.0, + "visibility": 5.5, + "ozone": 405.87, + "nearestStormDistance": 34.89, + "nearestStormBearing": 135 + }, + { + "time": 1770433200, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.72, + "apparentTemperature": -6.4, + "dewPoint": -6.04, + "humidity": 0.74, + "pressure": 1003.77, + "windSpeed": 3.6, + "windGust": 5.0, + "windBearing": 230, + "cloudCover": 0.89, + "uvIndex": 0.0, + "visibility": 2.43, + "ozone": 406.71, + "nearestStormDistance": 21.08, + "nearestStormBearing": 90 + }, + { + "time": 1770436800, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.89, + "apparentTemperature": -6.74, + "dewPoint": -6.17, + "humidity": 0.74, + "pressure": 1003.03, + "windSpeed": 3.87, + "windGust": 5.4, + "windBearing": 237, + "cloudCover": 0.86, + "uvIndex": 0.0, + "visibility": 2.73, + "ozone": 403.27, + "nearestStormDistance": 84.33, + "nearestStormBearing": 90 + }, + { + "time": 1770440400, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.05, + "apparentTemperature": -7.07, + "dewPoint": -6.3, + "humidity": 0.73, + "pressure": 1002.42, + "windSpeed": 4.13, + "windGust": 5.8, + "windBearing": 243, + "cloudCover": 0.84, + "uvIndex": 0.0, + "visibility": 3.03, + "ozone": 408.05, + "nearestStormDistance": 105.41, + "nearestStormBearing": 90 + }, + { + "time": 1770444000, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.22, + "apparentTemperature": -7.41, + "dewPoint": -6.43, + "humidity": 0.73, + "pressure": 1001.45, + "windSpeed": 4.4, + "windGust": 6.2, + "windBearing": 250, + "cloudCover": 0.81, + "uvIndex": 0.0, + "visibility": 3.33, + "ozone": 412.89, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770447600, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.29, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.58, + "apparentTemperature": -8.29, + "dewPoint": -6.62, + "humidity": 0.74, + "pressure": 1000.84, + "windSpeed": 5.33, + "windGust": 7.5, + "windBearing": 260, + "cloudCover": 0.8, + "uvIndex": 0.0, + "visibility": 5.6, + "ozone": 417.97, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770451200, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.24, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.95, + "apparentTemperature": -9.17, + "dewPoint": -6.82, + "humidity": 0.75, + "pressure": 1000.63, + "windSpeed": 6.27, + "windGust": 8.8, + "windBearing": 270, + "cloudCover": 0.78, + "uvIndex": 0.0, + "visibility": 8.4, + "ozone": 417.69, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770454800, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -3.31, + "apparentTemperature": -10.05, + "dewPoint": -7.01, + "humidity": 0.76, + "pressure": 1000.26, + "windSpeed": 7.2, + "windGust": 10.1, + "windBearing": 280, + "cloudCover": 0.77, + "uvIndex": 0.0, + "visibility": 2.3, + "ozone": 416.64, + "nearestStormDistance": 55.66, + "nearestStormBearing": 180 + }, + { + "time": 1770458400, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -4.07, + "apparentTemperature": -11.46, + "dewPoint": -8.19, + "humidity": 0.73, + "pressure": 1000.86, + "windSpeed": 8.13, + "windGust": 11.13, + "windBearing": 287, + "cloudCover": 0.73, + "uvIndex": 0.0, + "visibility": 2.5, + "ozone": 430.55, + "nearestStormDistance": 59.55, + "nearestStormBearing": 333 + }, + { + "time": 1770462000, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -4.82, + "apparentTemperature": -12.87, + "dewPoint": -9.36, + "humidity": 0.71, + "pressure": 1001.53, + "windSpeed": 9.07, + "windGust": 12.17, + "windBearing": 293, + "cloudCover": 0.69, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 444.39, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770465600, + "summary": "Windy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -5.58, + "apparentTemperature": -14.28, + "dewPoint": -10.54, + "humidity": 0.68, + "pressure": 1002.21, + "windSpeed": 10.0, + "windGust": 13.2, + "windBearing": 300, + "cloudCover": 0.65, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 445.07, + "nearestStormDistance": 101.31, + "nearestStormBearing": 63 + }, + { + "time": 1770469200, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -6.72, + "apparentTemperature": -15.95, + "dewPoint": -11.67, + "humidity": 0.68, + "pressure": 1003.26, + "windSpeed": 10.4, + "windGust": 14.17, + "windBearing": 303, + "cloudCover": 0.57, + "uvIndex": 0.21, + "visibility": 16.09, + "ozone": 446.52, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770472800, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.11, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -7.85, + "apparentTemperature": -17.63, + "dewPoint": -12.79, + "humidity": 0.68, + "pressure": 1003.8, + "windSpeed": 10.8, + "windGust": 15.13, + "windBearing": 307, + "cloudCover": 0.49, + "uvIndex": 1.08, + "visibility": 16.09, + "ozone": 451.89, + "nearestStormDistance": 68.99, + "nearestStormBearing": 108 + }, + { + "time": 1770476400, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.99, + "apparentTemperature": -19.3, + "dewPoint": -13.92, + "humidity": 0.68, + "pressure": 1004.89, + "windSpeed": 11.2, + "windGust": 16.1, + "windBearing": 310, + "cloudCover": 0.41, + "uvIndex": 2.15, + "visibility": 6.5, + "ozone": 449.97, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770480000, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.07, + "apparentTemperature": -19.26, + "dewPoint": -14.3, + "humidity": 0.66, + "pressure": 1005.63, + "windSpeed": 11.07, + "windGust": 16.07, + "windBearing": 313, + "cloudCover": 0.39, + "uvIndex": 2.87, + "visibility": 4.3, + "ozone": 447.68, + "nearestStormDistance": 129.75, + "nearestStormBearing": 80 + }, + { + "time": 1770483600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.14, + "apparentTemperature": -19.23, + "dewPoint": -14.68, + "humidity": 0.64, + "pressure": 1006.14, + "windSpeed": 10.93, + "windGust": 16.03, + "windBearing": 317, + "cloudCover": 0.36, + "uvIndex": 3.23, + "visibility": 9.5, + "ozone": 460.33, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770487200, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.22, + "apparentTemperature": -19.19, + "dewPoint": -15.06, + "humidity": 0.62, + "pressure": 1006.64, + "windSpeed": 10.8, + "windGust": 16.0, + "windBearing": 320, + "cloudCover": 0.34, + "uvIndex": 3.23, + "visibility": 16.09, + "ozone": 466.32, + "nearestStormDistance": 84.43, + "nearestStormBearing": 56 + }, + { + "time": 1770490800, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.12, + "apparentTemperature": -19.17, + "dewPoint": -15.37, + "humidity": 0.6, + "pressure": 1007.56, + "windSpeed": 10.93, + "windGust": 16.2, + "windBearing": 320, + "cloudCover": 0.31, + "uvIndex": 2.88, + "visibility": 16.09, + "ozone": 461.77, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770494400, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.02, + "apparentTemperature": -19.16, + "dewPoint": -15.67, + "humidity": 0.57, + "pressure": 1008.89, + "windSpeed": 11.07, + "windGust": 16.4, + "windBearing": 320, + "cloudCover": 0.28, + "uvIndex": 2.08, + "visibility": 16.09, + "ozone": 460.11, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770498000, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.92, + "apparentTemperature": -19.14, + "dewPoint": -15.98, + "humidity": 0.55, + "pressure": 1009.92, + "windSpeed": 11.2, + "windGust": 16.6, + "windBearing": 320, + "cloudCover": 0.25, + "uvIndex": 1.23, + "visibility": 16.09, + "ozone": 464.16, + "nearestStormDistance": 163.12, + "nearestStormBearing": 38 + }, + { + "time": 1770501600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.09, + "apparentTemperature": -19.43, + "dewPoint": -16.42, + "humidity": 0.54, + "pressure": 1011.26, + "windSpeed": 11.33, + "windGust": 16.13, + "windBearing": 320, + "cloudCover": 0.23, + "uvIndex": 0.43, + "visibility": 16.09, + "ozone": 482.48, + "nearestStormDistance": 129.29, + "nearestStormBearing": 99 + }, + { + "time": 1770505200, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.25, + "apparentTemperature": -19.71, + "dewPoint": -16.86, + "humidity": 0.53, + "pressure": 1013.04, + "windSpeed": 11.47, + "windGust": 15.67, + "windBearing": 320, + "cloudCover": 0.22, + "uvIndex": 0.01, + "visibility": 16.09, + "ozone": 484.32, + "nearestStormDistance": 105.41, + "nearestStormBearing": 90 + }, + { + "time": 1770508800, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.42, + "apparentTemperature": -20.0, + "dewPoint": -17.3, + "humidity": 0.52, + "pressure": 1014.7, + "windSpeed": 11.6, + "windGust": 15.2, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 476.4, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770512400, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.84, + "apparentTemperature": -20.51, + "dewPoint": -17.58, + "humidity": 0.53, + "pressure": 1015.83, + "windSpeed": 11.2, + "windGust": 14.73, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 488.47, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770516000, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.26, + "apparentTemperature": -21.02, + "dewPoint": -17.87, + "humidity": 0.53, + "pressure": 1016.42, + "windSpeed": 10.8, + "windGust": 14.27, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 488.94, + "nearestStormDistance": 42.17, + "nearestStormBearing": 90 + }, + { + "time": 1770519600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -10.68, + "apparentTemperature": -21.53, + "dewPoint": -18.15, + "humidity": 0.54, + "pressure": 1017.26, + "windSpeed": 10.4, + "windGust": 13.8, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 476.31, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770523200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.0, + "apparentTemperature": -21.6, + "dewPoint": -18.21, + "humidity": 0.55, + "pressure": 1017.96, + "windSpeed": 9.87, + "windGust": 13.07, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 472.62, + "nearestStormDistance": 84.43, + "nearestStormBearing": 56 + }, + { + "time": 1770526800, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.31, + "apparentTemperature": -21.67, + "dewPoint": -18.28, + "humidity": 0.55, + "pressure": 1018.54, + "windSpeed": 9.33, + "windGust": 12.33, + "windBearing": 320, + "cloudCover": 0.18, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 473.0, + "nearestStormDistance": 104.96, + "nearestStormBearing": 45 + }, + { + "time": 1770530400, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.63, + "apparentTemperature": -21.74, + "dewPoint": -18.34, + "humidity": 0.56, + "pressure": 1018.94, + "windSpeed": 8.8, + "windGust": 11.6, + "windBearing": 320, + "cloudCover": 0.18, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 471.38, + "nearestStormDistance": 128.27, + "nearestStormBearing": 36 + }, + { + "time": 1770534000, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.85, + "apparentTemperature": -21.99, + "dewPoint": -18.42, + "humidity": 0.57, + "pressure": 1019.57, + "windSpeed": 8.53, + "windGust": 11.43, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 469.74, + "nearestStormDistance": 163.12, + "nearestStormBearing": 38 + }, + { + "time": 1770537600, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.08, + "apparentTemperature": -22.23, + "dewPoint": -18.51, + "humidity": 0.58, + "pressure": 1020.57, + "windSpeed": 8.27, + "windGust": 11.27, + "windBearing": 320, + "cloudCover": 0.22, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 469.32, + "nearestStormDistance": 198.1, + "nearestStormBearing": 39 + }, + { + "time": 1770541200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.3, + "apparentTemperature": -22.48, + "dewPoint": -18.59, + "humidity": 0.59, + "pressure": 1020.96, + "windSpeed": 8.0, + "windGust": 11.1, + "windBearing": 320, + "cloudCover": 0.24, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 470.4, + "nearestStormDistance": 210.33, + "nearestStormBearing": 45 + }, + { + "time": 1770544800, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.29, + "apparentTemperature": -22.26, + "dewPoint": -18.48, + "humidity": 0.6, + "pressure": 1021.44, + "windSpeed": 7.73, + "windGust": 10.67, + "windBearing": 317, + "cloudCover": 0.26, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 467.82, + "nearestStormDistance": 223.94, + "nearestStormBearing": 49 + }, + { + "time": 1770548400, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.29, + "apparentTemperature": -22.05, + "dewPoint": -18.38, + "humidity": 0.61, + "pressure": 1021.88, + "windSpeed": 7.47, + "windGust": 10.23, + "windBearing": 313, + "cloudCover": 0.27, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 467.33, + "nearestStormDistance": 222.15, + "nearestStormBearing": 35 + }, + { + "time": 1770552000, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.28, + "apparentTemperature": -21.83, + "dewPoint": -18.27, + "humidity": 0.62, + "pressure": 1022.51, + "windSpeed": 7.2, + "windGust": 9.8, + "windBearing": 310, + "cloudCover": 0.29, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 470.61, + "nearestStormDistance": 222.15, + "nearestStormBearing": 35 + }, + { + "time": 1770555600, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.06, + "apparentTemperature": -21.37, + "dewPoint": -17.87, + "humidity": 0.63, + "pressure": 1023.17, + "windSpeed": 7.07, + "windGust": 9.83, + "windBearing": 313, + "cloudCover": 0.32, + "uvIndex": 0.23, + "visibility": 16.09, + "ozone": 475.74, + "nearestStormDistance": 198.1, + "nearestStormBearing": 39 + }, + { + "time": 1770559200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.85, + "apparentTemperature": -20.92, + "dewPoint": -17.48, + "humidity": 0.63, + "pressure": 1023.18, + "windSpeed": 6.93, + "windGust": 9.87, + "windBearing": 317, + "cloudCover": 0.35, + "uvIndex": 1.09, + "visibility": 16.09, + "ozone": 473.89, + "nearestStormDistance": 210.33, + "nearestStormBearing": 45 + }, + { + "time": 1770562800, + "summary": "Breezy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.63, + "apparentTemperature": -20.46, + "dewPoint": -17.08, + "humidity": 0.64, + "pressure": 1023.83, + "windSpeed": 6.8, + "windGust": 9.9, + "windBearing": 320, + "cloudCover": 0.38, + "uvIndex": 2.2, + "visibility": 16.09, + "ozone": 467.51, + "nearestStormDistance": 233.17, + "nearestStormBearing": 40 + }, + { + "time": 1770566400, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.88, + "apparentTemperature": -19.39, + "dewPoint": -16.45, + "humidity": 0.64, + "pressure": 1024.02, + "windSpeed": 6.53, + "windGust": 9.77, + "windBearing": 317, + "cloudCover": 0.41, + "uvIndex": 3.21, + "visibility": 16.09, + "ozone": 453.24, + "nearestStormDistance": 233.17, + "nearestStormBearing": 40 + }, + { + "time": 1770570000, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.12, + "apparentTemperature": -18.31, + "dewPoint": -15.82, + "humidity": 0.63, + "pressure": 1023.84, + "windSpeed": 6.27, + "windGust": 9.63, + "windBearing": 313, + "cloudCover": 0.45, + "uvIndex": 3.87, + "visibility": 16.09, + "ozone": 444.43, + "nearestStormDistance": 247.0, + "nearestStormBearing": 32 + }, + { + "time": 1770573600, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.37, + "apparentTemperature": -17.24, + "dewPoint": -15.19, + "humidity": 0.63, + "pressure": 1023.45, + "windSpeed": 6.0, + "windGust": 9.5, + "windBearing": 310, + "cloudCover": 0.48, + "uvIndex": 3.98, + "visibility": 16.09, + "ozone": 441.37, + "nearestStormDistance": 280.82, + "nearestStormBearing": 45 + }, + { + "time": 1770577200, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.97, + "apparentTemperature": -16.72, + "dewPoint": -14.92, + "humidity": 0.62, + "pressure": 1021.09, + "windSpeed": 6.07, + "windGust": 9.37, + "windBearing": 310, + "cloudCover": 0.49, + "uvIndex": 3.5, + "visibility": 16.09, + "ozone": 440.69, + "nearestStormDistance": 291.96, + "nearestStormBearing": 37 + }, + { + "time": 1770580800, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.58, + "apparentTemperature": -16.2, + "dewPoint": -14.64, + "humidity": 0.62, + "pressure": 1021.18, + "windSpeed": 6.13, + "windGust": 9.23, + "windBearing": 310, + "cloudCover": 0.51, + "uvIndex": 2.58, + "visibility": 16.09, + "ozone": 433.53, + "nearestStormDistance": 303.53, + "nearestStormBearing": 41 + } + ] + }, + "daily": { + "summary": "Snow next Friday, with high temperatures peaking at 2°C on Wednesday.", + "icon": "snow", + "data": [ + { + "time": 1770354000, + "summary": "Hazy overnight.", + "icon": "fog", + "sunriseTime": 1770379258, + "sunsetTime": 1770416384, + "moonPhase": 0.66, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770354000, + "precipProbability": 0.33, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -2.02, + "temperatureHighTime": 1770418800, + "temperatureLow": -4.82, + "temperatureLowTime": 1770462000, + "apparentTemperatureHigh": -5.66, + "apparentTemperatureHighTime": 1770411600, + "apparentTemperatureLow": -14.37, + "apparentTemperatureLowTime": 1770462000, + "dewPoint": -9.17, + "humidity": 0.71, + "pressure": 1007.52, + "windSpeed": 3.01, + "windGust": 4.09, + "windGustTime": 1770436800, + "windBearing": 281, + "cloudCover": 0.63, + "uvIndex": 3.7, + "uvIndexTime": 1770400800, + "visibility": 13.85, + "temperatureMin": -8.2, + "temperatureMinTime": 1770379200, + "temperatureMax": -1.72, + "temperatureMaxTime": 1770433200, + "apparentTemperatureMin": -13.29, + "apparentTemperatureMinTime": 1770379200, + "apparentTemperatureMax": -5.66, + "apparentTemperatureMaxTime": 1770411600 + }, + { + "time": 1770440400, + "summary": "Windy throughout the day.", + "icon": "wind", + "sunriseTime": 1770465591, + "sunsetTime": 1770502858, + "moonPhase": 0.69, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770440400, + "precipProbability": 0.33, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -5.58, + "temperatureHighTime": 1770465600, + "temperatureLow": -12.3, + "temperatureLowTime": 1770541200, + "apparentTemperatureHigh": -15.88, + "apparentTemperatureHighTime": 1770465600, + "apparentTemperatureLow": -21.7, + "apparentTemperatureLowTime": 1770519600, + "dewPoint": -13.05, + "humidity": 0.64, + "pressure": 1007.22, + "windSpeed": 9.57, + "windGust": 13.35, + "windGustTime": 1770498000, + "windBearing": 302, + "cloudCover": 0.45, + "uvIndex": 3.23, + "uvIndexTime": 1770483600, + "visibility": 11.95, + "temperatureMin": -11.0, + "temperatureMinTime": 1770523200, + "temperatureMax": -2.05, + "temperatureMaxTime": 1770440400, + "apparentTemperatureMin": -21.7, + "apparentTemperatureMinTime": 1770519600, + "apparentTemperatureMax": -7.86, + "apparentTemperatureMaxTime": 1770440400 + }, + { + "time": 1770526800, + "summary": "Breezy in the morning.", + "icon": "wind", + "sunriseTime": 1770551923, + "sunsetTime": 1770589332, + "moonPhase": 0.72, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770526800, + "precipProbability": 0.07, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -7.97, + "temperatureHighTime": 1770591600, + "temperatureLow": -10.64, + "temperatureLowTime": 1770634800, + "apparentTemperatureHigh": -14.7, + "apparentTemperatureHighTime": 1770584400, + "apparentTemperatureLow": -17.9, + "apparentTemperatureLowTime": 1770634800, + "dewPoint": -16.36, + "humidity": 0.61, + "pressure": 1022.11, + "windSpeed": 6.89, + "windGust": 9.71, + "windGustTime": 1770526800, + "windBearing": 313, + "cloudCover": 0.37, + "uvIndex": 3.98, + "uvIndexTime": 1770573600, + "visibility": 16.09, + "temperatureMin": -12.3, + "temperatureMinTime": 1770541200, + "temperatureMax": -7.86, + "temperatureMaxTime": 1770595200, + "apparentTemperatureMin": -21.66, + "apparentTemperatureMinTime": 1770541200, + "apparentTemperatureMax": -14.7, + "apparentTemperatureMaxTime": 1770584400 + }, + { + "time": 1770613200, + "summary": "Mostly clear until night.", + "icon": "clear-day", + "sunriseTime": 1770638253, + "sunsetTime": 1770675806, + "moonPhase": 0.75, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770613200, + "precipProbability": 0.07, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -4.77, + "temperatureHighTime": 1770670800, + "temperatureLow": -7.39, + "temperatureLowTime": 1770721200, + "apparentTemperatureHigh": -10.59, + "apparentTemperatureHighTime": 1770670800, + "apparentTemperatureLow": -14.19, + "apparentTemperatureLowTime": 1770714000, + "dewPoint": -13.37, + "humidity": 0.64, + "pressure": 1023.19, + "windSpeed": 5.64, + "windGust": 8.0, + "windGustTime": 1770670800, + "windBearing": 306, + "cloudCover": 0.35, + "uvIndex": 3.54, + "uvIndexTime": 1770660000, + "visibility": 16.09, + "temperatureMin": -10.96, + "temperatureMinTime": 1770638400, + "temperatureMax": -4.77, + "temperatureMaxTime": 1770670800, + "apparentTemperatureMin": -18.23, + "apparentTemperatureMinTime": 1770638400, + "apparentTemperatureMax": -10.59, + "apparentTemperatureMaxTime": 1770670800 + }, + { + "time": 1770699600, + "summary": "Mostly clear until evening.", + "icon": "clear-day", + "sunriseTime": 1770724581, + "sunsetTime": 1770762279, + "moonPhase": 0.78, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770699600, + "precipProbability": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -1.3, + "temperatureHighTime": 1770757200, + "temperatureLow": -4.83, + "temperatureLowTime": 1770807600, + "apparentTemperatureHigh": -6.5, + "apparentTemperatureHighTime": 1770757200, + "apparentTemperatureLow": -10.85, + "apparentTemperatureLowTime": 1770807600, + "dewPoint": -10.03, + "humidity": 0.65, + "pressure": 1021.43, + "windSpeed": 4.8, + "windGust": 6.78, + "windGustTime": 1770699600, + "windBearing": 303, + "cloudCover": 0.37, + "uvIndex": 4.35, + "uvIndexTime": 1770746400, + "visibility": 15.2, + "temperatureMin": -7.39, + "temperatureMinTime": 1770721200, + "temperatureMax": -1.3, + "temperatureMaxTime": 1770757200, + "apparentTemperatureMin": -14.19, + "apparentTemperatureMinTime": 1770714000, + "apparentTemperatureMax": -6.5, + "apparentTemperatureMaxTime": 1770757200 + }, + { + "time": 1770786000, + "summary": "Hazy in the afternoon.", + "icon": "fog", + "sunriseTime": 1770810908, + "sunsetTime": 1770848753, + "moonPhase": 0.81, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770786000, + "precipProbability": 0.08, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": 2.11, + "temperatureHighTime": 1770836400, + "temperatureLow": -5.5, + "temperatureLowTime": 1770865200, + "apparentTemperatureHigh": -2.01, + "apparentTemperatureHighTime": 1770836400, + "apparentTemperatureLow": -8.93, + "apparentTemperatureLowTime": 1770876000, + "dewPoint": -6.87, + "humidity": 0.78, + "pressure": 1018.44, + "windSpeed": 3.33, + "windGust": 7.15, + "windGustTime": 1770854400, + "windBearing": 303, + "cloudCover": 0.5, + "uvIndex": 0.47, + "uvIndexTime": 1770832800, + "visibility": 10.62, + "temperatureMin": -5.5, + "temperatureMinTime": 1770865200, + "temperatureMax": 2.11, + "temperatureMaxTime": 1770836400, + "apparentTemperatureMin": -10.85, + "apparentTemperatureMinTime": 1770811200, + "apparentTemperatureMax": -2.01, + "apparentTemperatureMaxTime": 1770836400 + }, + { + "time": 1770872400, + "summary": "Possible snow (< 4 cm.) starting in the evening.", + "icon": "partly-cloudy-day", + "sunriseTime": 1770897234, + "sunsetTime": 1770935226, + "moonPhase": 0.84, + "precipIntensity": 0.15, + "precipIntensityMax": 1.008, + "precipIntensityMaxTime": 1770955200, + "precipProbability": 0.12, + "precipAccumulation": 0.9236, + "precipType": "snow", + "temperatureHigh": -0.19, + "temperatureHighTime": 1770919200, + "temperatureLow": -2.21, + "temperatureLowTime": 1770962400, + "apparentTemperatureHigh": 1.12, + "apparentTemperatureHighTime": 1770919200, + "apparentTemperatureLow": -7.73, + "apparentTemperatureLowTime": 1770980400, + "dewPoint": -4.45, + "humidity": 0.78, + "pressure": 1023.22, + "windSpeed": 0.95, + "windGust": 11.32, + "windGustTime": 1770886800, + "windBearing": 248, + "cloudCover": 0.47, + "uvIndex": 3.94, + "uvIndexTime": 1770919200, + "visibility": 16.09, + "temperatureMin": -5.35, + "temperatureMinTime": 1770872400, + "temperatureMax": -0.19, + "temperatureMaxTime": 1770919200, + "apparentTemperatureMin": -8.93, + "apparentTemperatureMinTime": 1770876000, + "apparentTemperatureMax": 1.12, + "apparentTemperatureMaxTime": 1770919200 + }, + { + "time": 1770958800, + "summary": "Light snow (< 10 cm.) throughout the day.", + "icon": "snow", + "sunriseTime": 1770983559, + "sunsetTime": 1771021699, + "moonPhase": 0.87, + "precipIntensity": 0.381, + "precipIntensityMax": 1.368, + "precipIntensityMaxTime": 1770962400, + "precipProbability": 0.34, + "precipAccumulation": 4.0291, + "precipType": "snow", + "temperatureHigh": -0.77, + "temperatureHighTime": 1771023600, + "temperatureLow": -0.81, + "temperatureLowTime": 1771048800, + "apparentTemperatureHigh": -3.43, + "apparentTemperatureHighTime": 1771005600, + "apparentTemperatureLow": -5.95, + "apparentTemperatureLowTime": 1771059600, + "dewPoint": -2.67, + "humidity": 0.8, + "pressure": 1015.78, + "windSpeed": 3.24, + "windGust": 12.21, + "windGustTime": 1771038000, + "windBearing": 30, + "cloudCover": 0.36, + "uvIndex": 3.8, + "uvIndexTime": 1771005600, + "visibility": 16.09, + "temperatureMin": -2.21, + "temperatureMinTime": 1770962400, + "temperatureMax": -0.64, + "temperatureMaxTime": 1771027200, + "apparentTemperatureMin": -7.91, + "apparentTemperatureMinTime": 1770984000, + "apparentTemperatureMax": -3.43, + "apparentTemperatureMaxTime": 1771005600 + } + ] + }, + "alerts": [ + { + "title": "Extreme Cold Warning", + "regions": [ + "Eastern Passaic", + "Hudson", + "Western Bergen", + "Eastern Bergen", + "Western Essex", + "Eastern Essex", + "Western Union", + "Eastern Union", + "Putnam", + "Rockland", + "Northern Westchester", + "Southern Westchester", + "New York (Manhattan)", + "Bronx", + "Richmond (Staten Is.)", + "Kings (Brooklyn)", + "Northern Queens", + "Southern Queens" + ], + "severity": "Severe", + "time": 1770402120, + "expires": 1770458400, + "description": "* WHAT...For the Wind Advisory, northwest winds 20 to 30 mph with gusts up to 50 mph expected. For the Extreme Cold Warning, dangerously cold wind chills as low as 20 below expected.\n* WHERE...Portions of northeast New Jersey and southeast New York.\n* WHEN...For the Wind Advisory, from 9 AM Saturday to midnight EST Saturday Night. For the Extreme Cold Warning, from 10 AM Saturday to 1 PM EST Sunday.\n* IMPACTS...Gusty winds will blow around unsecured objects. Tree limbs could be blown down and a few power outages may result. The cold wind chills could cause frostbite on exposed skin in as little as 30 minutes.", + "uri": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.5df73ec191a300e305a2e7beb31cdbaded01fd49.004.1" + }, + { + "title": "Wind Advisory", + "regions": [ + "Eastern Passaic", + "Hudson", + "Western Bergen", + "Eastern Bergen", + "Western Essex", + "Eastern Essex", + "Western Union", + "Eastern Union", + "Putnam", + "Rockland", + "Northern Westchester", + "Southern Westchester", + "New York (Manhattan)", + "Bronx", + "Richmond (Staten Is.)", + "Kings (Brooklyn)", + "Northern Queens", + "Southern Queens" + ], + "severity": "Moderate", + "time": 1770402120, + "expires": 1770458400, + "description": "* WHAT...For the Wind Advisory, northwest winds 20 to 30 mph with gusts up to 50 mph expected. For the Extreme Cold Warning, dangerously cold wind chills as low as 20 below expected.\n* WHERE...Portions of northeast New Jersey and southeast New York.\n* WHEN...For the Wind Advisory, from 9 AM Saturday to midnight EST Saturday Night. For the Extreme Cold Warning, from 10 AM Saturday to 1 PM EST Sunday.\n* IMPACTS...Gusty winds will blow around unsecured objects. Tree limbs could be blown down and a few power outages may result. The cold wind chills could cause frostbite on exposed skin in as little as 30 minutes.", + "uri": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.5df73ec191a300e305a2e7beb31cdbaded01fd49.004.2" + } + ], + "flags": { + "sources": ["ETOPO1", "hrrrsubh", "rtma_ru", "hrrr_0-18", "nbm", "nbm_fire", "dwd_mosmix", "ecmwf_ifs", "hrrr_18-48", "gfs", "gefs"], + "sourceTimes": { + "hrrr_subh": "2026-02-06 19Z", + "rtma_ru": "2026-02-06 21:15Z", + "hrrr_0-18": "2026-02-06 19Z", + "nbm": "2026-02-03 23Z", + "nbm_fire": "2026-02-06 12Z", + "dwd_mosmix": "2026-02-06 20Z", + "ecmwf_ifs": "2026-02-06 12Z", + "hrrr_18-48": "2026-02-06 18Z", + "gfs": "2026-02-06 12Z", + "gefs": "2026-02-06 12Z" + }, + "nearest-station": 10.96, + "units": "si", + "version": "V2.9.1" + } +} diff --git a/tests/mocks/weather_smhi.json b/tests/mocks/weather_smhi.json new file mode 100644 index 0000000000..c08a6e85b0 --- /dev/null +++ b/tests/mocks/weather_smhi.json @@ -0,0 +1,1907 @@ +{ + "approvedTime": "2026-02-06T21:31:33Z", + "referenceTime": "2026-02-06T21:00:00Z", + "geometry": { "type": "Point", "coordinates": [[18.089437, 59.339222]] }, + "timeSeries": [ + { + "validTime": "2026-02-06T22:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [40] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1013.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-06T23:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1013.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T01:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [37] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T02:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [31] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.6] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T03:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [33] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T04:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [35] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [89] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T05:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [35] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [89] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [11.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [37] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1015.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T07:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [36] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1015.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T08:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [42] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1016.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [14.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T09:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [41] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [82] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1017.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [17.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T10:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [44] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [77] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1017.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [20.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T11:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [48] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [64] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [28.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [47] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [50] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [38.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T13:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [42] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [52] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T14:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [71] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [23.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T15:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [32] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [76] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T16:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [31] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1019.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T17:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1019.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [36] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1020.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T19:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [33] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1020.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T20:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [32] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [15.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [2] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T21:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [32] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [90] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [2] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T22:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [90] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [10.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [2] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T23:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [44] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [89] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1022.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [46] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1022.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T01:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [53] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1022.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T02:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [49] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T03:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [39] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T04:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [40] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [5.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T05:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [46] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [5.6] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [63] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T07:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [55] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [16.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T08:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [54] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [15.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T09:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [53] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [14.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T10:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [66] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [82] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T11:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [103] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [64] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [29.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [116] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [55] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T13:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [118] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [54] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T14:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [123] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [55] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [35.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T15:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [120] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [60] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T16:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [116] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [65] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T17:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [115] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [71] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [24.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [107] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [78] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [19.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T19:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [117] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [17.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T20:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [124] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T21:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [138] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T22:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [157] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T23:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [174] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [182] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [79] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T03:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [223] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [78] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [19.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-09T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [251] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [77] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1020.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [20.4] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-09T09:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [264] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [76] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1019.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [21.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [254] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1017.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [19.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [250] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1015.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [22.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [271] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [0.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1012.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [7.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-10.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [253] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1009.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [8.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [249] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1006.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [10.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-10.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [318] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1003.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.4] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-13.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [314] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [0.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [91] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1001.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-11.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [348] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [999.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [43.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [344] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [82] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [998.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [49.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [52] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [996.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [40.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [49] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [994.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [39.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.4] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [56] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [993.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [29.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [55] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [10.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [81] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [993.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.5] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [45] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [10.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [994.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.4] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-13T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [9.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [994.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.6] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [88] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-13T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [19] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [10.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [996.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [33.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.5] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-14T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-10.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [3] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [9.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [999.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [37.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-14T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [350] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [9.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [75] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1002.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [38.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-15T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-11.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [321] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1007.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [40.6] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-15T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [304] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [72] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1011.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [43.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.4] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-16T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [292] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1013.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [43.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-16T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [295] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [78] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [45.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + } + ] +} diff --git a/tests/mocks/weather_ukmetoffice.json b/tests/mocks/weather_ukmetoffice.json new file mode 100644 index 0000000000..1a5663e83c --- /dev/null +++ b/tests/mocks/weather_ukmetoffice.json @@ -0,0 +1,1062 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.12480000000000001, 51.5081, 11.0] }, + "properties": { + "location": { "name": "London" }, + "requestPointDistance": 221.7807, + "modelRunDate": "2026-02-07T12:00Z", + "timeSeries": [ + { + "time": "2026-02-07T12:00Z", + "screenTemperature": 9.56, + "maxScreenAirTemp": 9.56, + "minScreenAirTemp": 9.11, + "screenDewPointTemperature": 8.51, + "feelsLikeTemperature": 8.74, + "windSpeed10m": 1.9, + "windDirectionFrom10m": 165, + "windGustSpeed10m": 7.72, + "max10mWindGust": 9.32, + "visibility": 8550, + "screenRelativeHumidity": 93.08, + "mslp": 99440, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 15 + }, + { + "time": "2026-02-07T13:00Z", + "screenTemperature": 9.67, + "maxScreenAirTemp": 9.69, + "minScreenAirTemp": 9.56, + "screenDewPointTemperature": 8.39, + "feelsLikeTemperature": 8.76, + "windSpeed10m": 2.13, + "windDirectionFrom10m": 188, + "windGustSpeed10m": 7.31, + "max10mWindGust": 8.26, + "visibility": 7592, + "screenRelativeHumidity": 91.56, + "mslp": 99435, + "uvIndex": 1, + "significantWeatherCode": 11, + "precipitationRate": 0.06, + "totalPrecipAmount": 0.04, + "totalSnowAmount": 0, + "probOfPrecipitation": 33 + }, + { + "time": "2026-02-07T14:00Z", + "screenTemperature": 9.91, + "maxScreenAirTemp": 10.01, + "minScreenAirTemp": 9.67, + "screenDewPointTemperature": 8.62, + "feelsLikeTemperature": 8.29, + "windSpeed10m": 3.22, + "windDirectionFrom10m": 189, + "windGustSpeed10m": 8.15, + "max10mWindGust": 8.64, + "visibility": 9509, + "screenRelativeHumidity": 91.56, + "mslp": 99496, + "uvIndex": 1, + "significantWeatherCode": 14, + "precipitationRate": 1.5, + "totalPrecipAmount": 0.23, + "totalSnowAmount": 0, + "probOfPrecipitation": 62 + }, + { + "time": "2026-02-07T15:00Z", + "screenTemperature": 10.21, + "maxScreenAirTemp": 10.4, + "minScreenAirTemp": 9.91, + "screenDewPointTemperature": 8.5, + "feelsLikeTemperature": 8.19, + "windSpeed10m": 4.1, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 9.49, + "max10mWindGust": 9.56, + "visibility": 9666, + "screenRelativeHumidity": 89.1, + "mslp": 99550, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.24, + "totalPrecipAmount": 0.09, + "totalSnowAmount": 0, + "probOfPrecipitation": 55 + }, + { + "time": "2026-02-07T16:00Z", + "screenTemperature": 10.22, + "maxScreenAirTemp": 10.24, + "minScreenAirTemp": 10.18, + "screenDewPointTemperature": 8.24, + "feelsLikeTemperature": 8.28, + "windSpeed10m": 3.92, + "windDirectionFrom10m": 187, + "windGustSpeed10m": 8.95, + "max10mWindGust": 9.64, + "visibility": 7525, + "screenRelativeHumidity": 87.43, + "mslp": 99620, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.53, + "totalPrecipAmount": 0.08, + "totalSnowAmount": 0, + "probOfPrecipitation": 59 + }, + { + "time": "2026-02-07T17:00Z", + "screenTemperature": 9.99, + "maxScreenAirTemp": 10.22, + "minScreenAirTemp": 9.98, + "screenDewPointTemperature": 8.13, + "feelsLikeTemperature": 8.22, + "windSpeed10m": 3.51, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 8.31, + "max10mWindGust": 9.11, + "visibility": 11604, + "screenRelativeHumidity": 88.07, + "mslp": 99680, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-07T18:00Z", + "screenTemperature": 9.89, + "maxScreenAirTemp": 9.99, + "minScreenAirTemp": 9.84, + "screenDewPointTemperature": 8.13, + "feelsLikeTemperature": 8.07, + "windSpeed10m": 3.54, + "windDirectionFrom10m": 181, + "windGustSpeed10m": 8.86, + "max10mWindGust": 9.03, + "visibility": 11879, + "screenRelativeHumidity": 88.72, + "mslp": 99760, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-07T19:00Z", + "screenTemperature": 9.68, + "maxScreenAirTemp": 9.89, + "minScreenAirTemp": 9.67, + "screenDewPointTemperature": 8.06, + "feelsLikeTemperature": 7.86, + "windSpeed10m": 3.45, + "windDirectionFrom10m": 183, + "windGustSpeed10m": 8.57, + "max10mWindGust": 8.86, + "visibility": 12104, + "screenRelativeHumidity": 89.57, + "mslp": 99816, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-07T20:00Z", + "screenTemperature": 9.59, + "maxScreenAirTemp": 9.68, + "minScreenAirTemp": 9.57, + "screenDewPointTemperature": 8.02, + "feelsLikeTemperature": 7.96, + "windSpeed10m": 3.08, + "windDirectionFrom10m": 179, + "windGustSpeed10m": 8.15, + "max10mWindGust": 8.88, + "visibility": 12574, + "screenRelativeHumidity": 89.91, + "mslp": 99876, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, + { + "time": "2026-02-07T21:00Z", + "screenTemperature": 9.34, + "maxScreenAirTemp": 9.59, + "minScreenAirTemp": 9.34, + "screenDewPointTemperature": 8.01, + "feelsLikeTemperature": 7.65, + "windSpeed10m": 3.12, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 7.95, + "max10mWindGust": 8.46, + "visibility": 12829, + "screenRelativeHumidity": 91.36, + "mslp": 99932, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 3 + }, + { + "time": "2026-02-07T22:00Z", + "screenTemperature": 9.0, + "maxScreenAirTemp": 9.34, + "minScreenAirTemp": 8.98, + "screenDewPointTemperature": 7.71, + "feelsLikeTemperature": 7.27, + "windSpeed10m": 3.08, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 8.34, + "max10mWindGust": 8.76, + "visibility": 12923, + "screenRelativeHumidity": 91.6, + "mslp": 99986, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-07T23:00Z", + "screenTemperature": 8.74, + "maxScreenAirTemp": 8.98, + "minScreenAirTemp": 8.71, + "screenDewPointTemperature": 7.57, + "feelsLikeTemperature": 7.09, + "windSpeed10m": 2.86, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 7.68, + "max10mWindGust": 8.78, + "visibility": 14190, + "screenRelativeHumidity": 92.32, + "mslp": 100056, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T00:00Z", + "screenTemperature": 8.56, + "maxScreenAirTemp": 8.74, + "minScreenAirTemp": 8.56, + "screenDewPointTemperature": 7.59, + "feelsLikeTemperature": 7.12, + "windSpeed10m": 2.52, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 7.13, + "max10mWindGust": 8.49, + "visibility": 13732, + "screenRelativeHumidity": 93.62, + "mslp": 100096, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T01:00Z", + "screenTemperature": 8.4, + "maxScreenAirTemp": 8.56, + "minScreenAirTemp": 8.38, + "screenDewPointTemperature": 7.27, + "feelsLikeTemperature": 7.08, + "windSpeed10m": 2.32, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 6.73, + "max10mWindGust": 7.62, + "visibility": 14599, + "screenRelativeHumidity": 92.57, + "mslp": 100150, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T02:00Z", + "screenTemperature": 8.14, + "maxScreenAirTemp": 8.4, + "minScreenAirTemp": 8.13, + "screenDewPointTemperature": 7.17, + "feelsLikeTemperature": 7.11, + "windSpeed10m": 1.93, + "windDirectionFrom10m": 191, + "windGustSpeed10m": 5.96, + "max10mWindGust": 7.23, + "visibility": 12665, + "screenRelativeHumidity": 93.62, + "mslp": 100190, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T03:00Z", + "screenTemperature": 7.9, + "maxScreenAirTemp": 8.14, + "minScreenAirTemp": 7.89, + "screenDewPointTemperature": 7.12, + "feelsLikeTemperature": 7.1, + "windSpeed10m": 1.63, + "windDirectionFrom10m": 195, + "windGustSpeed10m": 5.28, + "max10mWindGust": 6.22, + "visibility": 10018, + "screenRelativeHumidity": 94.84, + "mslp": 100224, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T04:00Z", + "screenTemperature": 7.78, + "maxScreenAirTemp": 7.9, + "minScreenAirTemp": 7.76, + "screenDewPointTemperature": 7.07, + "feelsLikeTemperature": 6.86, + "windSpeed10m": 1.74, + "windDirectionFrom10m": 188, + "windGustSpeed10m": 5.13, + "max10mWindGust": 5.76, + "visibility": 8777, + "screenRelativeHumidity": 95.25, + "mslp": 100253, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T05:00Z", + "screenTemperature": 7.67, + "maxScreenAirTemp": 7.78, + "minScreenAirTemp": 7.62, + "screenDewPointTemperature": 7.02, + "feelsLikeTemperature": 6.77, + "windSpeed10m": 1.64, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 5.17, + "max10mWindGust": 5.88, + "visibility": 7296, + "screenRelativeHumidity": 95.73, + "mslp": 100280, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T06:00Z", + "screenTemperature": 7.52, + "maxScreenAirTemp": 7.67, + "minScreenAirTemp": 7.47, + "screenDewPointTemperature": 6.7, + "feelsLikeTemperature": 6.68, + "windSpeed10m": 1.6, + "windDirectionFrom10m": 183, + "windGustSpeed10m": 4.97, + "max10mWindGust": 5.64, + "visibility": 7420, + "screenRelativeHumidity": 94.66, + "mslp": 100327, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T07:00Z", + "screenTemperature": 7.63, + "maxScreenAirTemp": 7.64, + "minScreenAirTemp": 7.52, + "screenDewPointTemperature": 6.82, + "feelsLikeTemperature": 6.29, + "windSpeed10m": 2.18, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 5.54, + "max10mWindGust": 6.01, + "visibility": 7504, + "screenRelativeHumidity": 94.7, + "mslp": 100390, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T08:00Z", + "screenTemperature": 7.81, + "maxScreenAirTemp": 7.81, + "minScreenAirTemp": 7.63, + "screenDewPointTemperature": 7.06, + "feelsLikeTemperature": 6.72, + "windSpeed10m": 1.93, + "windDirectionFrom10m": 190, + "windGustSpeed10m": 4.93, + "max10mWindGust": 5.86, + "visibility": 6197, + "screenRelativeHumidity": 95.08, + "mslp": 100450, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 9 + }, + { + "time": "2026-02-08T09:00Z", + "screenTemperature": 8.12, + "maxScreenAirTemp": 8.13, + "minScreenAirTemp": 7.81, + "screenDewPointTemperature": 7.2, + "feelsLikeTemperature": 7.05, + "windSpeed10m": 1.95, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 4.53, + "max10mWindGust": 4.92, + "visibility": 6327, + "screenRelativeHumidity": 94.03, + "mslp": 100503, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T10:00Z", + "screenTemperature": 8.86, + "maxScreenAirTemp": 8.86, + "minScreenAirTemp": 8.12, + "screenDewPointTemperature": 7.54, + "feelsLikeTemperature": 7.73, + "windSpeed10m": 2.17, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 4.42, + "max10mWindGust": 4.54, + "visibility": 7222, + "screenRelativeHumidity": 91.55, + "mslp": 100533, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T11:00Z", + "screenTemperature": 9.57, + "maxScreenAirTemp": 9.57, + "minScreenAirTemp": 8.86, + "screenDewPointTemperature": 7.57, + "feelsLikeTemperature": 8.41, + "windSpeed10m": 2.37, + "windDirectionFrom10m": 181, + "windGustSpeed10m": 4.88, + "max10mWindGust": 4.88, + "visibility": 10651, + "screenRelativeHumidity": 87.5, + "mslp": 100560, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T12:00Z", + "screenTemperature": 10.27, + "maxScreenAirTemp": 10.28, + "minScreenAirTemp": 9.57, + "screenDewPointTemperature": 7.41, + "feelsLikeTemperature": 9.29, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 185, + "windGustSpeed10m": 4.71, + "max10mWindGust": 4.71, + "visibility": 12395, + "screenRelativeHumidity": 82.51, + "mslp": 100560, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T13:00Z", + "screenTemperature": 10.75, + "maxScreenAirTemp": 10.76, + "minScreenAirTemp": 10.27, + "screenDewPointTemperature": 6.87, + "feelsLikeTemperature": 9.48, + "windSpeed10m": 2.77, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 5.56, + "max10mWindGust": 5.56, + "visibility": 14708, + "screenRelativeHumidity": 76.97, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T14:00Z", + "screenTemperature": 10.84, + "maxScreenAirTemp": 10.88, + "minScreenAirTemp": 10.75, + "screenDewPointTemperature": 6.71, + "feelsLikeTemperature": 9.4, + "windSpeed10m": 3.1, + "windDirectionFrom10m": 186, + "windGustSpeed10m": 6.12, + "max10mWindGust": 6.29, + "visibility": 16685, + "screenRelativeHumidity": 75.74, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T15:00Z", + "screenTemperature": 10.76, + "maxScreenAirTemp": 10.84, + "minScreenAirTemp": 10.73, + "screenDewPointTemperature": 6.67, + "feelsLikeTemperature": 9.29, + "windSpeed10m": 3.11, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 6.07, + "max10mWindGust": 6.26, + "visibility": 16963, + "screenRelativeHumidity": 75.87, + "mslp": 100527, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T16:00Z", + "screenTemperature": 10.36, + "maxScreenAirTemp": 10.76, + "minScreenAirTemp": 10.33, + "screenDewPointTemperature": 6.66, + "feelsLikeTemperature": 8.88, + "windSpeed10m": 3.07, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 5.99, + "max10mWindGust": 6.33, + "visibility": 17519, + "screenRelativeHumidity": 77.89, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T17:00Z", + "screenTemperature": 9.94, + "maxScreenAirTemp": 10.36, + "minScreenAirTemp": 9.93, + "screenDewPointTemperature": 6.86, + "feelsLikeTemperature": 8.52, + "windSpeed10m": 2.84, + "windDirectionFrom10m": 179, + "windGustSpeed10m": 5.58, + "max10mWindGust": 6.05, + "visibility": 16071, + "screenRelativeHumidity": 81.23, + "mslp": 100550, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T18:00Z", + "screenTemperature": 9.55, + "maxScreenAirTemp": 9.94, + "minScreenAirTemp": 9.54, + "screenDewPointTemperature": 6.97, + "feelsLikeTemperature": 8.1, + "windSpeed10m": 2.81, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 5.68, + "max10mWindGust": 6.14, + "visibility": 15755, + "screenRelativeHumidity": 83.93, + "mslp": 100560, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2026-02-08T19:00Z", + "screenTemperature": 9.23, + "maxScreenAirTemp": 9.55, + "minScreenAirTemp": 9.22, + "screenDewPointTemperature": 7.05, + "feelsLikeTemperature": 7.71, + "windSpeed10m": 2.83, + "windDirectionFrom10m": 168, + "windGustSpeed10m": 5.67, + "max10mWindGust": 6.27, + "visibility": 14548, + "screenRelativeHumidity": 86.32, + "mslp": 100547, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 18 + }, + { + "time": "2026-02-08T20:00Z", + "screenTemperature": 9.05, + "maxScreenAirTemp": 9.23, + "minScreenAirTemp": 9.04, + "screenDewPointTemperature": 7.13, + "feelsLikeTemperature": 7.66, + "windSpeed10m": 2.57, + "windDirectionFrom10m": 173, + "windGustSpeed10m": 5.24, + "max10mWindGust": 6.08, + "visibility": 13961, + "screenRelativeHumidity": 87.79, + "mslp": 100547, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 17 + }, + { + "time": "2026-02-08T21:00Z", + "screenTemperature": 8.81, + "maxScreenAirTemp": 9.05, + "minScreenAirTemp": 8.81, + "screenDewPointTemperature": 7.2, + "feelsLikeTemperature": 7.4, + "windSpeed10m": 2.56, + "windDirectionFrom10m": 163, + "windGustSpeed10m": 5.38, + "max10mWindGust": 5.73, + "visibility": 13739, + "screenRelativeHumidity": 89.7, + "mslp": 100540, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.07, + "totalPrecipAmount": 0.2, + "totalSnowAmount": 0, + "probOfPrecipitation": 45 + }, + { + "time": "2026-02-08T22:00Z", + "screenTemperature": 8.74, + "maxScreenAirTemp": 8.81, + "minScreenAirTemp": 8.72, + "screenDewPointTemperature": 7.12, + "feelsLikeTemperature": 7.36, + "windSpeed10m": 2.47, + "windDirectionFrom10m": 164, + "windGustSpeed10m": 5.43, + "max10mWindGust": 5.67, + "visibility": 11395, + "screenRelativeHumidity": 89.66, + "mslp": 100530, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 0.23, + "totalPrecipAmount": 0.18, + "totalSnowAmount": 0, + "probOfPrecipitation": 44 + }, + { + "time": "2026-02-08T23:00Z", + "screenTemperature": 8.57, + "maxScreenAirTemp": 8.74, + "minScreenAirTemp": 8.53, + "screenDewPointTemperature": 7.23, + "feelsLikeTemperature": 7.31, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 149, + "windGustSpeed10m": 5.28, + "max10mWindGust": 5.87, + "visibility": 10051, + "screenRelativeHumidity": 91.35, + "mslp": 100497, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.22, + "totalPrecipAmount": 0.26, + "totalSnowAmount": 0, + "probOfPrecipitation": 54 + }, + { + "time": "2026-02-09T00:00Z", + "screenTemperature": 8.52, + "maxScreenAirTemp": 8.57, + "minScreenAirTemp": 8.49, + "screenDewPointTemperature": 7.17, + "feelsLikeTemperature": 7.21, + "windSpeed10m": 2.32, + "windDirectionFrom10m": 151, + "windGustSpeed10m": 5.44, + "max10mWindGust": 5.96, + "visibility": 13108, + "screenRelativeHumidity": 91.42, + "mslp": 100475, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 13 + }, + { + "time": "2026-02-09T01:00Z", + "screenTemperature": 8.39, + "maxScreenAirTemp": 8.52, + "minScreenAirTemp": 8.36, + "screenDewPointTemperature": 7.08, + "feelsLikeTemperature": 6.94, + "windSpeed10m": 2.49, + "windDirectionFrom10m": 157, + "windGustSpeed10m": 5.83, + "max10mWindGust": 6.54, + "visibility": 14678, + "screenRelativeHumidity": 91.55, + "mslp": 100430, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2026-02-09T02:00Z", + "screenTemperature": 8.23, + "maxScreenAirTemp": 8.39, + "minScreenAirTemp": 8.18, + "screenDewPointTemperature": 6.88, + "feelsLikeTemperature": 6.86, + "windSpeed10m": 2.34, + "windDirectionFrom10m": 155, + "windGustSpeed10m": 5.35, + "max10mWindGust": 6.7, + "visibility": 13081, + "screenRelativeHumidity": 91.35, + "mslp": 100385, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 16 + }, + { + "time": "2026-02-09T03:00Z", + "screenTemperature": 8.1, + "maxScreenAirTemp": 8.23, + "minScreenAirTemp": 8.05, + "screenDewPointTemperature": 6.78, + "feelsLikeTemperature": 6.67, + "windSpeed10m": 2.37, + "windDirectionFrom10m": 150, + "windGustSpeed10m": 5.35, + "max10mWindGust": 6.67, + "visibility": 15140, + "screenRelativeHumidity": 91.56, + "mslp": 100335, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-09T04:00Z", + "screenTemperature": 7.9, + "maxScreenAirTemp": 8.1, + "minScreenAirTemp": 7.86, + "screenDewPointTemperature": 6.58, + "feelsLikeTemperature": 6.41, + "windSpeed10m": 2.39, + "windDirectionFrom10m": 149, + "windGustSpeed10m": 5.43, + "max10mWindGust": 6.53, + "visibility": 15366, + "screenRelativeHumidity": 91.65, + "mslp": 100305, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-09T05:00Z", + "screenTemperature": 7.71, + "maxScreenAirTemp": 7.9, + "minScreenAirTemp": 7.65, + "screenDewPointTemperature": 6.51, + "feelsLikeTemperature": 6.28, + "windSpeed10m": 2.3, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.3, + "max10mWindGust": 6.91, + "visibility": 14570, + "screenRelativeHumidity": 92.33, + "mslp": 100283, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T06:00Z", + "screenTemperature": 7.56, + "maxScreenAirTemp": 7.71, + "minScreenAirTemp": 7.54, + "screenDewPointTemperature": 6.38, + "feelsLikeTemperature": 6.11, + "windSpeed10m": 2.29, + "windDirectionFrom10m": 148, + "windGustSpeed10m": 5.34, + "max10mWindGust": 6.56, + "visibility": 13685, + "screenRelativeHumidity": 92.49, + "mslp": 100280, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T07:00Z", + "screenTemperature": 7.61, + "maxScreenAirTemp": 7.62, + "minScreenAirTemp": 7.56, + "screenDewPointTemperature": 6.43, + "feelsLikeTemperature": 6.17, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.26, + "max10mWindGust": 6.34, + "visibility": 13185, + "screenRelativeHumidity": 92.48, + "mslp": 100282, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-09T08:00Z", + "screenTemperature": 7.7, + "maxScreenAirTemp": 7.75, + "minScreenAirTemp": 7.61, + "screenDewPointTemperature": 6.48, + "feelsLikeTemperature": 6.25, + "windSpeed10m": 2.34, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.57, + "max10mWindGust": 5.76, + "visibility": 13541, + "screenRelativeHumidity": 92.21, + "mslp": 100275, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T09:00Z", + "screenTemperature": 7.92, + "maxScreenAirTemp": 7.92, + "minScreenAirTemp": 7.7, + "screenDewPointTemperature": 6.53, + "feelsLikeTemperature": 6.42, + "windSpeed10m": 2.43, + "windDirectionFrom10m": 142, + "windGustSpeed10m": 5.54, + "max10mWindGust": 6.4, + "visibility": 13747, + "screenRelativeHumidity": 91.19, + "mslp": 100275, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T10:00Z", + "screenTemperature": 8.6, + "maxScreenAirTemp": 8.65, + "minScreenAirTemp": 7.92, + "screenDewPointTemperature": 6.57, + "feelsLikeTemperature": 7.09, + "windSpeed10m": 2.66, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.71, + "max10mWindGust": 5.71, + "visibility": 14552, + "screenRelativeHumidity": 87.48, + "mslp": 100241, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T11:00Z", + "screenTemperature": 9.43, + "maxScreenAirTemp": 9.43, + "minScreenAirTemp": 8.6, + "screenDewPointTemperature": 6.49, + "feelsLikeTemperature": 7.83, + "windSpeed10m": 3.0, + "windDirectionFrom10m": 151, + "windGustSpeed10m": 6.25, + "max10mWindGust": 6.25, + "visibility": 19055, + "screenRelativeHumidity": 82.28, + "mslp": 100209, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T12:00Z", + "screenTemperature": 10.25, + "screenDewPointTemperature": 6.37, + "feelsLikeTemperature": 8.61, + "windSpeed10m": 3.28, + "windDirectionFrom10m": 155, + "windGustSpeed10m": 6.87, + "visibility": 20517, + "screenRelativeHumidity": 77.18, + "mslp": 100150, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "probOfPrecipitation": 6 + } + ] + } + } + ], + "parameters": [ + { + "totalSnowAmount": { "type": "Parameter", "description": "Total Snow Amount Over Previous Hour", "unit": { "label": "millimetres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm" } } }, + "screenTemperature": { "type": "Parameter", "description": "Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "visibility": { "type": "Parameter", "description": "Visibility", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "windDirectionFrom10m": { "type": "Parameter", "description": "10m Wind From Direction", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "precipitationRate": { "type": "Parameter", "description": "Precipitation Rate", "unit": { "label": "millimetres per hour", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm/h" } } }, + "maxScreenAirTemp": { "type": "Parameter", "description": "Maximum Screen Air Temperature Over Previous Hour", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "feelsLikeTemperature": { "type": "Parameter", "description": "Feels Like Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "screenDewPointTemperature": { "type": "Parameter", "description": "Screen Dew Point Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "screenRelativeHumidity": { "type": "Parameter", "description": "Screen Relative Humidity", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "windSpeed10m": { "type": "Parameter", "description": "10m Wind Speed", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "probOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "max10mWindGust": { "type": "Parameter", "description": "Maximum 10m Wind Gust Speed Over Previous Hour", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "significantWeatherCode": { "type": "Parameter", "description": "Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "minScreenAirTemp": { "type": "Parameter", "description": "Minimum Screen Air Temperature Over Previous Hour", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "totalPrecipAmount": { "type": "Parameter", "description": "Total Precipitation Amount Over Previous Hour", "unit": { "label": "millimetres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm" } } }, + "mslp": { "type": "Parameter", "description": "Mean Sea Level Pressure", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "windGustSpeed10m": { "type": "Parameter", "description": "10m Wind Gust Speed", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "uvIndex": { "type": "Parameter", "description": "UV Index", "unit": { "label": "dimensionless", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "1" } } } + } + ] +} diff --git a/tests/mocks/weather_ukmetoffice_daily.json b/tests/mocks/weather_ukmetoffice_daily.json new file mode 100644 index 0000000000..e774a0f575 --- /dev/null +++ b/tests/mocks/weather_ukmetoffice_daily.json @@ -0,0 +1,419 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.12480000000000001, 51.5081, 11.0] }, + "properties": { + "location": { "name": "London" }, + "requestPointDistance": 221.7807, + "modelRunDate": "2026-02-07T12:00Z", + "timeSeries": [ + { + "time": "2026-02-06T00:00Z", + "midday10MWindSpeed": 0.82, + "midnight10MWindSpeed": 1.59, + "midday10MWindDirection": 121, + "midnight10MWindDirection": 175, + "midday10MWindGust": 3.09, + "midnight10MWindGust": 7.72, + "middayVisibility": 4000, + "midnightVisibility": 12560, + "middayRelativeHumidity": 92.93, + "midnightRelativeHumidity": 89.85, + "middayMslp": 98480, + "midnightMslp": 99260, + "nightSignificantWeatherCode": 2, + "dayMaxScreenTemperature": 11.37, + "nightMinScreenTemperature": 7.26, + "dayUpperBoundMaxTemp": 12.53, + "nightUpperBoundMinTemp": 8.77, + "dayLowerBoundMaxTemp": 9.86, + "nightLowerBoundMinTemp": 6.62, + "nightMinFeelsLikeTemp": 5.98, + "dayUpperBoundMaxFeelsLikeTemp": 11.58, + "nightUpperBoundMinFeelsLikeTemp": 6.83, + "dayLowerBoundMaxFeelsLikeTemp": 8.89, + "nightLowerBoundMinFeelsLikeTemp": 5.23, + "nightProbabilityOfPrecipitation": 85, + "nightProbabilityOfSnow": 0, + "nightProbabilityOfHeavySnow": 0, + "nightProbabilityOfRain": 85, + "nightProbabilityOfHeavyRain": 80, + "nightProbabilityOfHail": 16, + "nightProbabilityOfSferics": 8 + }, + { + "time": "2026-02-07T00:00Z", + "midday10MWindSpeed": 1.9, + "midnight10MWindSpeed": 2.52, + "midday10MWindDirection": 165, + "midnight10MWindDirection": 184, + "midday10MWindGust": 7.72, + "midnight10MWindGust": 7.13, + "middayVisibility": 8550, + "midnightVisibility": 13732, + "middayRelativeHumidity": 93.08, + "midnightRelativeHumidity": 93.62, + "middayMslp": 99440, + "midnightMslp": 100100, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 10.5, + "nightMinScreenTemperature": 7.52, + "dayUpperBoundMaxTemp": 11.72, + "nightUpperBoundMinTemp": 9.39, + "dayLowerBoundMaxTemp": 9.78, + "nightLowerBoundMinTemp": 5.83, + "dayMaxFeelsLikeTemp": 8.76, + "nightMinFeelsLikeTemp": 6.29, + "dayUpperBoundMaxFeelsLikeTemp": 9.47, + "nightUpperBoundMinFeelsLikeTemp": 8.28, + "dayLowerBoundMaxFeelsLikeTemp": 8.06, + "nightLowerBoundMinFeelsLikeTemp": 5.22, + "dayProbabilityOfPrecipitation": 91, + "nightProbabilityOfPrecipitation": 10, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 91, + "nightProbabilityOfRain": 10, + "dayProbabilityOfHeavyRain": 86, + "nightProbabilityOfHeavyRain": 2, + "dayProbabilityOfHail": 17, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 11, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2026-02-08T00:00Z", + "midday10MWindSpeed": 2.28, + "midnight10MWindSpeed": 2.32, + "midday10MWindDirection": 185, + "midnight10MWindDirection": 151, + "midday10MWindGust": 4.71, + "midnight10MWindGust": 5.44, + "middayVisibility": 12395, + "midnightVisibility": 13108, + "middayRelativeHumidity": 82.51, + "midnightRelativeHumidity": 91.42, + "middayMslp": 100559, + "midnightMslp": 100474, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 11.07, + "nightMinScreenTemperature": 7.56, + "dayUpperBoundMaxTemp": 11.84, + "nightUpperBoundMinTemp": 8.59, + "dayLowerBoundMaxTemp": 9.76, + "nightLowerBoundMinTemp": 5.18, + "dayMaxFeelsLikeTemp": 9.48, + "nightMinFeelsLikeTemp": 6.11, + "dayUpperBoundMaxFeelsLikeTemp": 10.97, + "nightUpperBoundMinFeelsLikeTemp": 7.32, + "dayLowerBoundMaxFeelsLikeTemp": 8.13, + "nightLowerBoundMinFeelsLikeTemp": 5.38, + "dayProbabilityOfPrecipitation": 10, + "nightProbabilityOfPrecipitation": 54, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 10, + "nightProbabilityOfRain": 54, + "dayProbabilityOfHeavyRain": 2, + "nightProbabilityOfHeavyRain": 32, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 4, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 6 + }, + { + "time": "2026-02-09T00:00Z", + "midday10MWindSpeed": 3.28, + "midnight10MWindSpeed": 3.42, + "midday10MWindDirection": 155, + "midnight10MWindDirection": 121, + "midday10MWindGust": 6.87, + "midnight10MWindGust": 7.02, + "middayVisibility": 20517, + "midnightVisibility": 18708, + "middayRelativeHumidity": 77.18, + "midnightRelativeHumidity": 86.28, + "middayMslp": 100150, + "midnightMslp": 99580, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 10.89, + "nightMinScreenTemperature": 6.93, + "dayUpperBoundMaxTemp": 11.87, + "nightUpperBoundMinTemp": 8.61, + "dayLowerBoundMaxTemp": 8.55, + "nightLowerBoundMinTemp": 4.78, + "dayMaxFeelsLikeTemp": 9.06, + "nightMinFeelsLikeTemp": 5.13, + "dayUpperBoundMaxFeelsLikeTemp": 9.87, + "nightUpperBoundMinFeelsLikeTemp": 6.29, + "dayLowerBoundMaxFeelsLikeTemp": 6.57, + "nightLowerBoundMinFeelsLikeTemp": 3.3, + "dayProbabilityOfPrecipitation": 6, + "nightProbabilityOfPrecipitation": 18, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 6, + "nightProbabilityOfRain": 18, + "dayProbabilityOfHeavyRain": 1, + "nightProbabilityOfHeavyRain": 7, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2026-02-10T00:00Z", + "midday10MWindSpeed": 3.09, + "midnight10MWindSpeed": 3.12, + "midday10MWindDirection": 150, + "midnight10MWindDirection": 191, + "midday10MWindGust": 6.52, + "midnight10MWindGust": 6.18, + "middayVisibility": 17148, + "midnightVisibility": 12750, + "middayRelativeHumidity": 86.68, + "midnightRelativeHumidity": 93.78, + "middayMslp": 98991, + "midnightMslp": 98238, + "maxUvIndex": 1, + "daySignificantWeatherCode": 8, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 10.47, + "nightMinScreenTemperature": 8.75, + "dayUpperBoundMaxTemp": 13.15, + "nightUpperBoundMinTemp": 10.63, + "dayLowerBoundMaxTemp": 7.91, + "nightLowerBoundMinTemp": 6.14, + "dayMaxFeelsLikeTemp": 8.44, + "nightMinFeelsLikeTemp": 7.65, + "dayUpperBoundMaxFeelsLikeTemp": 11.11, + "nightUpperBoundMinFeelsLikeTemp": 8.73, + "dayLowerBoundMaxFeelsLikeTemp": 6.87, + "nightLowerBoundMinFeelsLikeTemp": 5.59, + "dayProbabilityOfPrecipitation": 49, + "nightProbabilityOfPrecipitation": 54, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 49, + "nightProbabilityOfRain": 54, + "dayProbabilityOfHeavyRain": 26, + "nightProbabilityOfHeavyRain": 32, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 2, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 2 + }, + { + "time": "2026-02-11T00:00Z", + "midday10MWindSpeed": 4.2, + "midnight10MWindSpeed": 3.4, + "midday10MWindDirection": 228, + "midnight10MWindDirection": 241, + "midday10MWindGust": 9.23, + "midnight10MWindGust": 6.88, + "middayVisibility": 20709, + "midnightVisibility": 18608, + "middayRelativeHumidity": 82.88, + "midnightRelativeHumidity": 89.7, + "middayMslp": 98098, + "midnightMslp": 97870, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 11.71, + "nightMinScreenTemperature": 7.6, + "dayUpperBoundMaxTemp": 13.23, + "nightUpperBoundMinTemp": 9.77, + "dayLowerBoundMaxTemp": 7.85, + "nightLowerBoundMinTemp": 4.71, + "dayMaxFeelsLikeTemp": 9.43, + "nightMinFeelsLikeTemp": 5.5, + "dayUpperBoundMaxFeelsLikeTemp": 11.39, + "nightUpperBoundMinFeelsLikeTemp": 7.6, + "dayLowerBoundMaxFeelsLikeTemp": 8.0, + "nightLowerBoundMinFeelsLikeTemp": 4.33, + "dayProbabilityOfPrecipitation": 50, + "nightProbabilityOfPrecipitation": 46, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 50, + "nightProbabilityOfRain": 46, + "dayProbabilityOfHeavyRain": 28, + "nightProbabilityOfHeavyRain": 25, + "dayProbabilityOfHail": 2, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 5, + "nightProbabilityOfSferics": 4 + }, + { + "time": "2026-02-12T00:00Z", + "midday10MWindSpeed": 3.99, + "midnight10MWindSpeed": 3.62, + "midday10MWindDirection": 297, + "midnight10MWindDirection": 321, + "midday10MWindGust": 8.71, + "midnight10MWindGust": 7.52, + "middayVisibility": 21894, + "midnightVisibility": 24612, + "middayRelativeHumidity": 80.41, + "midnightRelativeHumidity": 83.6, + "middayMslp": 98255, + "midnightMslp": 98981, + "maxUvIndex": 1, + "daySignificantWeatherCode": 8, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 9.92, + "nightMinScreenTemperature": 3.15, + "dayUpperBoundMaxTemp": 12.14, + "nightUpperBoundMinTemp": 9.05, + "dayLowerBoundMaxTemp": 5.26, + "nightLowerBoundMinTemp": 0.41, + "dayMaxFeelsLikeTemp": 6.69, + "nightMinFeelsLikeTemp": -0.03, + "dayUpperBoundMaxFeelsLikeTemp": 10.16, + "nightUpperBoundMinFeelsLikeTemp": 7.3, + "dayLowerBoundMaxFeelsLikeTemp": 4.45, + "nightLowerBoundMinFeelsLikeTemp": -3.64, + "dayProbabilityOfPrecipitation": 21, + "nightProbabilityOfPrecipitation": 22, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 2, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 1, + "dayProbabilityOfRain": 21, + "nightProbabilityOfRain": 22, + "dayProbabilityOfHeavyRain": 9, + "nightProbabilityOfHeavyRain": 10, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 2, + "nightProbabilityOfSferics": 2 + }, + { + "time": "2026-02-13T00:00Z", + "midday10MWindSpeed": 4.14, + "midnight10MWindSpeed": 2.83, + "midday10MWindDirection": 322, + "midnight10MWindDirection": 307, + "midday10MWindGust": 9.4, + "midnight10MWindGust": 5.92, + "middayVisibility": 34312, + "midnightVisibility": 34597, + "middayRelativeHumidity": 66.26, + "midnightRelativeHumidity": 77.93, + "middayMslp": 99718, + "midnightMslp": 100243, + "maxUvIndex": 1, + "daySignificantWeatherCode": 1, + "nightSignificantWeatherCode": 0, + "dayMaxScreenTemperature": 6.79, + "nightMinScreenTemperature": 0.85, + "dayUpperBoundMaxTemp": 11.07, + "nightUpperBoundMinTemp": 7.3, + "dayLowerBoundMaxTemp": 2.84, + "nightLowerBoundMinTemp": -1.75, + "dayMaxFeelsLikeTemp": 2.21, + "nightMinFeelsLikeTemp": -2.05, + "dayUpperBoundMaxFeelsLikeTemp": 9.57, + "nightUpperBoundMinFeelsLikeTemp": 6.72, + "dayLowerBoundMaxFeelsLikeTemp": -0.29, + "nightLowerBoundMinFeelsLikeTemp": -3.77, + "dayProbabilityOfPrecipitation": 11, + "nightProbabilityOfPrecipitation": 6, + "dayProbabilityOfSnow": 2, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 1, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 9, + "nightProbabilityOfRain": 6, + "dayProbabilityOfHeavyRain": 4, + "nightProbabilityOfHeavyRain": 3, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 0 + } + ] + } + } + ], + "parameters": [ + { + "daySignificantWeatherCode": { "type": "Parameter", "description": "Day Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "midnightRelativeHumidity": { "type": "Parameter", "description": "Relative Humidity at Local Midnight", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfHeavyRain": { "type": "Parameter", "description": "Probability of Heavy Rain During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midnight10MWindSpeed": { "type": "Parameter", "description": "10m Wind Speed at Local Midnight", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "nightUpperBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightUpperBoundMinTemp": { "type": "Parameter", "description": "Upper Bound on Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "midnightVisibility": { "type": "Parameter", "description": "Visibility at Local Midnight", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "dayUpperBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightProbabilityOfRain": { "type": "Parameter", "description": "Probability of Rain During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midday10MWindDirection": { "type": "Parameter", "description": "10m Wind Direction at Local Midday", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "nightLowerBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightProbabilityOfHail": { "type": "Parameter", "description": "Probability of Hail During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "middayMslp": { "type": "Parameter", "description": "Mean Sea Level Pressure at Local Midday", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "dayProbabilityOfHeavySnow": { "type": "Parameter", "description": "Probability of Heavy Snow During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfHail": { "type": "Parameter", "description": "Probability of Hail During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfRain": { "type": "Parameter", "description": "Probability of Rain During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midday10MWindSpeed": { "type": "Parameter", "description": "10m Wind Speed at Local Midday", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "midday10MWindGust": { "type": "Parameter", "description": "10m Wind Gust Speed at Local Midday", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "middayVisibility": { "type": "Parameter", "description": "Visibility at Local Midday", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "midnight10MWindGust": { "type": "Parameter", "description": "10m Wind Gust Speed at Local Midnight", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "midnightMslp": { "type": "Parameter", "description": "Mean Sea Level Pressure at Local Midnight", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "dayProbabilityOfSferics": { "type": "Parameter", "description": "Probability of Sferics During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightSignificantWeatherCode": { "type": "Parameter", "description": "Night Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "dayProbabilityOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfHeavyRain": { "type": "Parameter", "description": "Probability of Heavy Rain During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayMaxScreenTemperature": { "type": "Parameter", "description": "Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightMinScreenTemperature": { "type": "Parameter", "description": "Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "midnight10MWindDirection": { "type": "Parameter", "description": "10m Wind Direction at Local Midnight", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "maxUvIndex": { "type": "Parameter", "description": "Day Maximum UV Index", "unit": { "label": "dimensionless", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "1" } } }, + "dayProbabilityOfSnow": { "type": "Parameter", "description": "Probability of Snow During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfSnow": { "type": "Parameter", "description": "Probability of Snow During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayLowerBoundMaxTemp": { "type": "Parameter", "description": "Lower Bound on Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightProbabilityOfHeavySnow": { "type": "Parameter", "description": "Probability of Heavy Snow During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayLowerBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "dayUpperBoundMaxTemp": { "type": "Parameter", "description": "Upper Bound on Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "dayMaxFeelsLikeTemp": { "type": "Parameter", "description": "Day Maximum Feels Like Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "middayRelativeHumidity": { "type": "Parameter", "description": "Relative Humidity at Local Midday", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightLowerBoundMinTemp": { "type": "Parameter", "description": "Lower Bound on Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightMinFeelsLikeTemp": { "type": "Parameter", "description": "Night Minimum Feels Like Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightProbabilityOfSferics": { "type": "Parameter", "description": "Probability of Sferics During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } } + } + ] +} diff --git a/tests/mocks/weather_weatherbit.json b/tests/mocks/weather_weatherbit.json new file mode 100644 index 0000000000..bc8dfb530e --- /dev/null +++ b/tests/mocks/weather_weatherbit.json @@ -0,0 +1,45 @@ +{ + "count": 1, + "data": [ + { + "app_temp": -0.6, + "aqi": 44, + "city_name": "New York City", + "clouds": 100, + "country_code": "US", + "datetime": "2026-02-06:21", + "dewpt": -9, + "dhi": 62, + "dni": 555, + "elev_angle": 12.55, + "ghi": 175, + "gust": 3.1, + "h_angle": 60, + "lat": 40.7128, + "lon": -74.006, + "ob_time": "2026-02-06 21:25", + "pod": "d", + "precip": 0, + "pres": 1004, + "rh": 47, + "slp": 1004, + "snow": 0, + "solar_rad": 35, + "sources": ["KJRB", "radar", "satellite"], + "state_code": "NY", + "station": "KJRB", + "sunrise": "11:59", + "sunset": "22:21", + "temp": 1, + "timezone": "America/New_York", + "ts": 1770413100, + "uv": 0, + "vis": 16, + "weather": { "code": 804, "description": "Overcast clouds", "icon": "c04d" }, + "wind_cdir": "SSW", + "wind_cdir_full": "south-southwest", + "wind_dir": 210, + "wind_spd": 1.5 + } + ] +} diff --git a/tests/mocks/weather_weatherbit_forecast.json b/tests/mocks/weather_weatherbit_forecast.json new file mode 100644 index 0000000000..b5239b8436 --- /dev/null +++ b/tests/mocks/weather_weatherbit_forecast.json @@ -0,0 +1,290 @@ +{ + "city_name": "New York City", + "country_code": "US", + "data": [ + { + "app_max_temp": -2.7, + "app_min_temp": -9.8, + "clouds": 76, + "clouds_hi": 8, + "clouds_low": 40, + "clouds_mid": 90, + "datetime": "2026-02-06", + "dewpt": -7.6, + "high_temp": 0.8, + "low_temp": -6.5, + "max_dhi": null, + "max_temp": 0.5, + "min_temp": -6.3, + "moon_phase": 0.68, + "moon_phase_lunation": 0.65, + "moonrise_ts": 1770432076, + "moonset_ts": 1770388275, + "ozone": 405, + "pop": 50, + "precip": 0.25, + "pres": 1005, + "rh": 66, + "slp": 1008, + "snow": 3.75, + "snow_depth": 216.91895, + "sunrise_ts": 1770379197, + "sunset_ts": 1770416511, + "temp": -2, + "ts": 1770372060, + "uv": 1, + "valid_date": "2026-02-06", + "vis": 21.7, + "weather": { "code": 600, "description": "Light snow", "icon": "s01d" }, + "wind_cdir": "SSW", + "wind_cdir_full": "south-southwest", + "wind_dir": 192, + "wind_gust_spd": 3.4, + "wind_spd": 2.4 + }, + { + "app_max_temp": -4.9, + "app_min_temp": -22.5, + "clouds": 76, + "clouds_hi": 0, + "clouds_low": 87, + "clouds_mid": 57, + "datetime": "2026-02-07", + "dewpt": -13.7, + "high_temp": -5.9, + "low_temp": -13.9, + "max_dhi": null, + "max_temp": -0.2, + "min_temp": -12.2, + "moon_phase": 0.59, + "moon_phase_lunation": 0.68, + "moonrise_ts": 1770522306, + "moonset_ts": 1770476147, + "ozone": 452, + "pop": 0, + "precip": 0, + "pres": 1006, + "rh": 61, + "slp": 1009, + "snow": 0, + "snow_depth": 201.76189, + "sunrise_ts": 1770465530, + "sunset_ts": 1770502984, + "temp": -7.5, + "ts": 1770440460, + "uv": 1, + "valid_date": "2026-02-07", + "vis": 17.8, + "weather": { "code": 804, "description": "Overcast clouds", "icon": "c04d" }, + "wind_cdir": "NW", + "wind_cdir_full": "northwest", + "wind_dir": 307, + "wind_gust_spd": 13.2, + "wind_spd": 9.6 + }, + { + "app_max_temp": -16.5, + "app_min_temp": -23.8, + "clouds": 9, + "clouds_hi": 0, + "clouds_low": 17, + "clouds_mid": 0, + "datetime": "2026-02-08", + "dewpt": -18.1, + "high_temp": -7.5, + "low_temp": -12.4, + "max_dhi": null, + "max_temp": -7.5, + "min_temp": -13.9, + "moon_phase": 0.49, + "moon_phase_lunation": 0.71, + "moonrise_ts": 1770612516, + "moonset_ts": 1770564257, + "ozone": 453, + "pop": 0, + "precip": 0, + "pres": 1021, + "rh": 54, + "slp": 1024, + "snow": 0, + "snow_depth": 190.89763, + "sunrise_ts": 1770551862, + "sunset_ts": 1770589457, + "temp": -10.7, + "ts": 1770526860, + "uv": 3, + "valid_date": "2026-02-08", + "vis": 24, + "weather": { "code": 801, "description": "Few clouds", "icon": "c02d" }, + "wind_cdir": "NW", + "wind_cdir_full": "northwest", + "wind_dir": 321, + "wind_gust_spd": 11.3, + "wind_spd": 8.2 + }, + { + "app_max_temp": -7.5, + "app_min_temp": -18.4, + "clouds": 38, + "clouds_hi": 23, + "clouds_low": 36, + "clouds_mid": 53, + "datetime": "2026-02-09", + "dewpt": -13.7, + "high_temp": -1.6, + "low_temp": -7.4, + "max_dhi": null, + "max_temp": -1.6, + "min_temp": -12.4, + "moon_phase": 0.4, + "moon_phase_lunation": 0.75, + "moonrise_ts": 1770616323, + "moonset_ts": 1770652706, + "ozone": 379, + "pop": 0, + "precip": 0, + "pres": 1021, + "rh": 59, + "slp": 1024, + "snow": 0, + "snow_depth": 174.14348, + "sunrise_ts": 1770638193, + "sunset_ts": 1770675930, + "temp": -7, + "ts": 1770613260, + "uv": 2, + "valid_date": "2026-02-09", + "vis": 23.5, + "weather": { "code": 802, "description": "Scattered clouds", "icon": "c02d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 301, + "wind_gust_spd": 5.9, + "wind_spd": 4.3 + }, + { + "app_max_temp": -3.1, + "app_min_temp": -11.5, + "clouds": 36, + "clouds_hi": 45, + "clouds_low": 39, + "clouds_mid": 25, + "datetime": "2026-02-10", + "dewpt": -8.5, + "high_temp": 1.5, + "low_temp": -3, + "max_dhi": null, + "max_temp": 1.5, + "min_temp": -7.4, + "moon_phase": 0.3, + "moon_phase_lunation": 0.78, + "moonrise_ts": 1770706492, + "moonset_ts": 1770741592, + "ozone": 348, + "pop": 0, + "precip": 0, + "pres": 1018, + "rh": 65, + "slp": 1021, + "snow": 0, + "snow_depth": 150.55084, + "sunrise_ts": 1770724522, + "sunset_ts": 1770762403, + "temp": -2.9, + "ts": 1770699660, + "uv": 3, + "valid_date": "2026-02-10", + "vis": 23, + "weather": { "code": 802, "description": "Scattered clouds", "icon": "c02d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 290, + "wind_gust_spd": 3.6, + "wind_spd": 3.5 + }, + { + "app_max_temp": -1.4, + "app_min_temp": -5.8, + "clouds": 73, + "clouds_hi": 97, + "clouds_low": 55, + "clouds_mid": 81, + "datetime": "2026-02-11", + "dewpt": -4.2, + "high_temp": 3.8, + "low_temp": -3.4, + "max_dhi": null, + "max_temp": 3.8, + "min_temp": -3, + "moon_phase": 0.22, + "moon_phase_lunation": 0.81, + "moonrise_ts": 1770796533, + "moonset_ts": 1770830974, + "ozone": 327, + "pop": 80, + "precip": 6.33, + "pres": 1012, + "rh": 72, + "slp": 1014, + "snow": 8.82, + "snow_depth": 103.88888, + "sunrise_ts": 1770810850, + "sunset_ts": 1770848875, + "temp": 0.3, + "ts": 1770786060, + "uv": 2, + "valid_date": "2026-02-11", + "vis": 16.7, + "weather": { "code": 500, "description": "Light rain", "icon": "r01d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 302, + "wind_gust_spd": 4.2, + "wind_spd": 4.2 + }, + { + "app_max_temp": -5.3, + "app_min_temp": -10.9, + "clouds": 40, + "clouds_hi": 0, + "clouds_low": 40, + "clouds_mid": 0, + "datetime": "2026-02-12", + "dewpt": -5.2, + "high_temp": 0, + "low_temp": -6.3, + "max_dhi": null, + "max_temp": 0, + "min_temp": -4.6, + "moon_phase": 0.14, + "moon_phase_lunation": 0.85, + "moonrise_ts": 1770886312, + "moonset_ts": 1770920833, + "ozone": 378, + "pop": 20, + "precip": 0.125, + "pres": 1010, + "rh": 79, + "slp": 1013, + "snow": 0.625, + "snow_depth": 49.526215, + "sunrise_ts": 1770897177, + "sunset_ts": 1770935347, + "temp": -2, + "ts": 1770872460, + "uv": 4, + "valid_date": "2026-02-12", + "vis": 24, + "weather": { "code": 610, "description": "Mix snow/rain", "icon": "s04d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 299, + "wind_gust_spd": 12, + "wind_spd": 5.3 + } + ], + "lat": 40.7128, + "lon": -74.006, + "state_code": "NY", + "timezone": "America/New_York" +} diff --git a/tests/mocks/weather_weatherbit_hourly.json b/tests/mocks/weather_weatherbit_hourly.json new file mode 100644 index 0000000000..d277750081 --- /dev/null +++ b/tests/mocks/weather_weatherbit_hourly.json @@ -0,0 +1 @@ +{ "error": "Your API key does not allow access to this endpoint." } diff --git a/tests/mocks/weather_weatherflow.json b/tests/mocks/weather_weatherflow.json new file mode 100644 index 0000000000..38e4fbdbed --- /dev/null +++ b/tests/mocks/weather_weatherflow.json @@ -0,0 +1,4875 @@ +{ + "current_conditions": { + "air_density": 1.22, + "air_temperature": 16.0, + "brightness": 22546, + "conditions": "Clear", + "delta_t": 8.0, + "dew_point": -2.0, + "feels_like": 16.0, + "icon": "clear-day", + "is_precip_local_day_rain_check": true, + "is_precip_local_yesterday_rain_check": true, + "lightning_strike_count_last_1hr": 0, + "lightning_strike_count_last_3hr": 0, + "lightning_strike_last_distance": 25, + "lightning_strike_last_distance_msg": "23 - 27 km", + "lightning_strike_last_epoch": 1765159221, + "precip_accum_local_day": 0, + "precip_accum_local_yesterday": 1.75, + "precip_minutes_local_day": 0, + "precip_minutes_local_yesterday": 141, + "precip_probability": 0, + "pressure_trend": "falling", + "relative_humidity": 28, + "sea_level_pressure": 1013.6, + "solar_radiation": 188, + "station_pressure": 1013.0, + "time": 1770414299, + "uv": 1, + "wet_bulb_globe_temperature": 10.0, + "wet_bulb_temperature": 8.0, + "wind_avg": 15.0, + "wind_direction": 269, + "wind_direction_cardinal": "W", + "wind_gust": 20.0 + }, + "forecast": { + "daily": [ + { + "air_temp_high": 15.0, + "air_temp_low": 4.0, + "conditions": "Clear", + "day_num": 6, + "day_start_local": 1770354000, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770379765, + "sunset": 1770419180 + }, + { + "air_temp_high": 14.0, + "air_temp_low": 10.0, + "conditions": "Clear", + "day_num": 7, + "day_start_local": 1770440400, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770466123, + "sunset": 1770505628 + }, + { + "air_temp_high": 16.0, + "air_temp_low": 10.0, + "conditions": "Clear", + "day_num": 8, + "day_start_local": 1770526800, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770552481, + "sunset": 1770592076 + }, + { + "air_temp_high": 18.0, + "air_temp_low": 9.0, + "conditions": "Clear", + "day_num": 9, + "day_start_local": 1770613200, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770638837, + "sunset": 1770678523 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 10.0, + "conditions": "Partly Cloudy", + "day_num": 10, + "day_start_local": 1770699600, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770725192, + "sunset": 1770764970 + }, + { + "air_temp_high": 21.0, + "air_temp_low": 13.0, + "conditions": "Partly Cloudy", + "day_num": 11, + "day_start_local": 1770786000, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770811546, + "sunset": 1770851417 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 12, + "day_start_local": 1770872400, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770897898, + "sunset": 1770937863 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 13, + "day_start_local": 1770958800, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "sunrise": 1770984250, + "sunset": 1771024309 + }, + { + "air_temp_high": 19.0, + "air_temp_low": 14.0, + "conditions": "Rain Possible", + "day_num": 14, + "day_start_local": 1771045200, + "icon": "possibly-rainy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 20, + "precip_type": "rain", + "sunrise": 1771070600, + "sunset": 1771110755 + }, + { + "air_temp_high": 19.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 15, + "day_start_local": 1771131600, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "sunrise": 1771156949, + "sunset": 1771197200 + } + ], + "hourly": [ + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 6, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 42, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1770415200, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 40.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 6, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 45, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1770418800, + "uv": 0.0, + "wind_avg": 32.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 44.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 52, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770422400, + "uv": 0.0, + "wind_avg": 33.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1015.3, + "station_pressure": 1014.9, + "time": 1770426000, + "uv": 0.0, + "wind_avg": 35.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1015.2, + "station_pressure": 1014.8, + "time": 1770429600, + "uv": 0.0, + "wind_avg": 33.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 50.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1015.2, + "station_pressure": 1014.8, + "time": 1770433200, + "uv": 0.0, + "wind_avg": 32.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770436800, + "uv": 0.0, + "wind_avg": 30.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 47.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770440400, + "uv": 0.0, + "wind_avg": 29.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 45.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1015.1, + "station_pressure": 1014.7, + "time": 1770444000, + "uv": 0.0, + "wind_avg": 27.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1014.9, + "station_pressure": 1014.5, + "time": 1770447600, + "uv": 0.0, + "wind_avg": 27.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770451200, + "uv": 0.0, + "wind_avg": 26.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1015.1, + "station_pressure": 1014.7, + "time": 1770454800, + "uv": 0.0, + "wind_avg": 26.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 42.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1015.6, + "station_pressure": 1015.2, + "time": 1770458400, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 41.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1770462000, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770465600, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 39.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770469200, + "uv": 1.0, + "wind_avg": 26.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 39.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1019.8, + "station_pressure": 1019.4, + "time": 1770472800, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770476400, + "uv": 4.0, + "wind_avg": 29.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 64, + "sea_level_pressure": 1020.8, + "station_pressure": 1020.4, + "time": 1770480000, + "uv": 5.0, + "wind_avg": 29.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1020.7, + "station_pressure": 1020.3, + "time": 1770483600, + "uv": 6.0, + "wind_avg": 29.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1020.0, + "station_pressure": 1019.6, + "time": 1770487200, + "uv": 7.0, + "wind_avg": 27.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1019.8, + "station_pressure": 1019.4, + "time": 1770490800, + "uv": 7.0, + "wind_avg": 27.0, + "wind_direction": 360, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1019.9, + "station_pressure": 1019.5, + "time": 1770494400, + "uv": 6.0, + "wind_avg": 27.0, + "wind_direction": 0, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770498000, + "uv": 4.0, + "wind_avg": 27.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1021.1, + "station_pressure": 1020.7, + "time": 1770501600, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1021.6, + "station_pressure": 1021.2, + "time": 1770505200, + "uv": 1.0, + "wind_avg": 26.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1022.3, + "station_pressure": 1021.9, + "time": 1770508800, + "uv": 0.0, + "wind_avg": 23.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 34.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770512400, + "uv": 0.0, + "wind_avg": 23.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 32.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770516000, + "uv": 0.0, + "wind_avg": 22.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 32.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1023.7, + "station_pressure": 1023.3, + "time": 1770519600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 31.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.0, + "station_pressure": 1023.6, + "time": 1770523200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 28.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770526800, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 28.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770530400, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 26.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770534000, + "uv": 0.0, + "wind_avg": 15.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 26.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770537600, + "uv": 0.0, + "wind_avg": 15.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 25.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1024.3, + "station_pressure": 1023.9, + "time": 1770541200, + "uv": 0.0, + "wind_avg": 14.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 25.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 70, + "sea_level_pressure": 1024.7, + "station_pressure": 1024.3, + "time": 1770544800, + "uv": 0.0, + "wind_avg": 14.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 24.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 8.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1025.1, + "station_pressure": 1024.7, + "time": 1770548400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 8.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1025.4, + "station_pressure": 1025.0, + "time": 1770552000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1025.9, + "station_pressure": 1025.5, + "time": 1770555600, + "uv": 4.0, + "wind_avg": 13.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1026.4, + "station_pressure": 1026.0, + "time": 1770559200, + "uv": 4.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.9, + "station_pressure": 1026.5, + "time": 1770562800, + "uv": 4.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 62, + "sea_level_pressure": 1026.4, + "station_pressure": 1026.0, + "time": 1770566400, + "uv": 6.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.0, + "station_pressure": 1025.6, + "time": 1770570000, + "uv": 6.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 60, + "sea_level_pressure": 1025.5, + "station_pressure": 1025.1, + "time": 1770573600, + "uv": 6.0, + "wind_avg": 16.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770577200, + "uv": 5.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 62, + "sea_level_pressure": 1025.1, + "station_pressure": 1024.7, + "time": 1770580800, + "uv": 5.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 20.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 8, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1025.0, + "station_pressure": 1024.6, + "time": 1770584400, + "uv": 5.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 19.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770588000, + "uv": 1.0, + "wind_avg": 12.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 18.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1025.7, + "station_pressure": 1025.3, + "time": 1770591600, + "uv": 1.0, + "wind_avg": 10.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770595200, + "uv": 1.0, + "wind_avg": 7.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 14.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770598800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 13.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770602400, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 12.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1026.2, + "station_pressure": 1025.8, + "time": 1770606000, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1026.0, + "station_pressure": 1025.6, + "time": 1770609600, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1025.9, + "station_pressure": 1025.5, + "time": 1770613200, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1025.8, + "station_pressure": 1025.4, + "time": 1770616800, + "uv": 0.0, + "wind_avg": 4.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770620400, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 12.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 10.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770624000, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 12.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1024.3, + "station_pressure": 1023.9, + "time": 1770627600, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 13.0 + }, + { + "air_temperature": 9.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1024.7, + "station_pressure": 1024.3, + "time": 1770631200, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 14.0 + }, + { + "air_temperature": 9.0, + "conditions": "Partly Cloudy", + "feels_like": 8.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1025.0, + "station_pressure": 1024.6, + "time": 1770634800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 14.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770638400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 15.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-day", + "local_day": 9, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1025.8, + "station_pressure": 1025.4, + "time": 1770642000, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 15.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-day", + "local_day": 9, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1026.3, + "station_pressure": 1025.9, + "time": 1770645600, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1026.8, + "station_pressure": 1026.4, + "time": 1770649200, + "uv": 2.0, + "wind_avg": 9.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.2, + "station_pressure": 1025.8, + "time": 1770652800, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1025.5, + "station_pressure": 1025.1, + "time": 1770656400, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 17.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 57, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770660000, + "uv": 4.0, + "wind_avg": 12.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 17.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 57, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770663600, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 18.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 56, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770667200, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 18.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 56, + "sea_level_pressure": 1022.7, + "station_pressure": 1022.3, + "time": 1770670800, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 19.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770674400, + "uv": 3.0, + "wind_avg": 11.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 18.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1023.1, + "station_pressure": 1022.7, + "time": 1770678000, + "uv": 3.0, + "wind_avg": 10.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 16.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1023.3, + "station_pressure": 1022.9, + "time": 1770681600, + "uv": 3.0, + "wind_avg": 9.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770685200, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1023.5, + "station_pressure": 1023.1, + "time": 1770688800, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1023.6, + "station_pressure": 1023.2, + "time": 1770692400, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770696000, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 10, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1023.2, + "station_pressure": 1022.8, + "time": 1770699600, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1023.0, + "station_pressure": 1022.6, + "time": 1770703200, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 19.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770706800, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770710400, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1022.8, + "station_pressure": 1022.4, + "time": 1770714000, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 16.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1023.2, + "station_pressure": 1022.8, + "time": 1770717600, + "uv": 0.0, + "wind_avg": 8.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 10.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1023.5, + "station_pressure": 1023.1, + "time": 1770721200, + "uv": 0.0, + "wind_avg": 8.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1023.9, + "station_pressure": 1023.5, + "time": 1770724800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770728400, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1024.5, + "station_pressure": 1024.1, + "time": 1770732000, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770735600, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770739200, + "uv": 4.0, + "wind_avg": 8.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1023.3, + "station_pressure": 1022.9, + "time": 1770742800, + "uv": 4.0, + "wind_avg": 8.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 60, + "sea_level_pressure": 1022.5, + "station_pressure": 1022.1, + "time": 1770746400, + "uv": 4.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1021.9, + "station_pressure": 1021.5, + "time": 1770750000, + "uv": 5.0, + "wind_avg": 9.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 14.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1021.3, + "station_pressure": 1020.9, + "time": 1770753600, + "uv": 5.0, + "wind_avg": 10.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 15.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1020.6, + "station_pressure": 1020.2, + "time": 1770757200, + "uv": 5.0, + "wind_avg": 10.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 15.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1021.1, + "station_pressure": 1020.7, + "time": 1770760800, + "uv": 3.0, + "wind_avg": 9.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 15.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770764400, + "uv": 3.0, + "wind_avg": 8.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 14.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1022.0, + "station_pressure": 1021.6, + "time": 1770768000, + "uv": 3.0, + "wind_avg": 7.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1022.2, + "station_pressure": 1021.8, + "time": 1770771600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1022.4, + "station_pressure": 1022.0, + "time": 1770775200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Cloudy", + "feels_like": 16.0, + "icon": "cloudy", + "local_day": 10, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1022.6, + "station_pressure": 1022.2, + "time": 1770778800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 16.0, + "conditions": "Cloudy", + "feels_like": 16.0, + "icon": "cloudy", + "local_day": 10, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1022.4, + "station_pressure": 1022.0, + "time": 1770782400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Cloudy", + "feels_like": 15.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1022.3, + "station_pressure": 1021.9, + "time": 1770786000, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Cloudy", + "feels_like": 15.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1022.1, + "station_pressure": 1021.7, + "time": 1770789600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1021.8, + "station_pressure": 1021.4, + "time": 1770793200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1021.6, + "station_pressure": 1021.2, + "time": 1770796800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1021.4, + "station_pressure": 1021.0, + "time": 1770800400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.4, + "station_pressure": 1021.0, + "time": 1770804000, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770807600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770811200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.7, + "station_pressure": 1021.3, + "time": 1770814800, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1022.0, + "station_pressure": 1021.6, + "time": 1770818400, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1022.2, + "station_pressure": 1021.8, + "time": 1770822000, + "uv": 2.0, + "wind_avg": 9.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1021.3, + "station_pressure": 1020.9, + "time": 1770825600, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 18.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770829200, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 18.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1019.5, + "station_pressure": 1019.1, + "time": 1770832800, + "uv": 4.0, + "wind_avg": 12.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Partly Cloudy", + "feels_like": 21.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1019.1, + "station_pressure": 1018.7, + "time": 1770836400, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Clear", + "feels_like": 21.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770840000, + "uv": 5.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Clear", + "feels_like": 21.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770843600, + "uv": 5.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 20.0, + "conditions": "Clear", + "feels_like": 20.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1018.4, + "station_pressure": 1018.0, + "time": 1770847200, + "uv": 3.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 20.0, + "conditions": "Clear", + "feels_like": 20.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1018.7, + "station_pressure": 1018.3, + "time": 1770850800, + "uv": 3.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 19.0, + "conditions": "Clear", + "feels_like": 19.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770854400, + "uv": 3.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1019.1, + "station_pressure": 1018.7, + "time": 1770858000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1019.3, + "station_pressure": 1018.9, + "time": 1770861600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1019.4, + "station_pressure": 1019.0, + "time": 1770865200, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770868800, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770872400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770876000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770879600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.7, + "station_pressure": 1017.3, + "time": 1770883200, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770886800, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 97, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770890400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 99, + "sea_level_pressure": 1018.2, + "station_pressure": 1017.8, + "time": 1770894000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 98, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770897600, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 100, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770901200, + "uv": 2.0, + "wind_avg": 13.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1018.7, + "station_pressure": 1018.3, + "time": 1770904800, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1018.8, + "station_pressure": 1018.4, + "time": 1770908400, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770912000, + "uv": 4.0, + "wind_avg": 15.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770915600, + "uv": 4.0, + "wind_avg": 15.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1770919200, + "uv": 4.0, + "wind_avg": 16.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1770922800, + "uv": 1.0, + "wind_avg": 16.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.1, + "station_pressure": 1015.7, + "time": 1770926400, + "uv": 1.0, + "wind_avg": 15.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1015.7, + "station_pressure": 1015.3, + "time": 1770930000, + "uv": 1.0, + "wind_avg": 15.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1016.2, + "station_pressure": 1015.8, + "time": 1770933600, + "uv": 1.0, + "wind_avg": 14.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 21.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1016.7, + "station_pressure": 1016.3, + "time": 1770937200, + "uv": 1.0, + "wind_avg": 13.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 21.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770940800, + "uv": 1.0, + "wind_avg": 12.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1017.4, + "station_pressure": 1017.0, + "time": 1770944400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1770948000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1017.8, + "station_pressure": 1017.4, + "time": 1770951600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1770955200, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770958800, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.3, + "station_pressure": 1016.9, + "time": 1770962400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770966000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.1, + "station_pressure": 1016.7, + "time": 1770969600, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1017.0, + "station_pressure": 1016.6, + "time": 1770973200, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770976800, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770980400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 24.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1018.3, + "station_pressure": 1017.9, + "time": 1770984000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 24.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770987600, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770991200, + "uv": 2.0, + "wind_avg": 15.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1019.4, + "station_pressure": 1019.0, + "time": 1770994800, + "uv": 2.0, + "wind_avg": 16.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1018.9, + "station_pressure": 1018.5, + "time": 1770998400, + "uv": 4.0, + "wind_avg": 17.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 25.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1771002000, + "uv": 4.0, + "wind_avg": 17.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1771005600, + "uv": 4.0, + "wind_avg": 18.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771009200, + "uv": 5.0, + "wind_avg": 18.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1017.1, + "station_pressure": 1016.7, + "time": 1771012800, + "uv": 5.0, + "wind_avg": 18.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.6, + "station_pressure": 1016.2, + "time": 1771016400, + "uv": 5.0, + "wind_avg": 19.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1016.9, + "station_pressure": 1016.5, + "time": 1771020000, + "uv": 3.0, + "wind_avg": 18.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 26.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1771023600, + "uv": 3.0, + "wind_avg": 17.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 26.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 13, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1771027200, + "uv": 3.0, + "wind_avg": 16.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 25.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771030800, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771034400, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1017.7, + "station_pressure": 1017.3, + "time": 1771038000, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1771041600, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1771045200, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1771048800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 2, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1015.9, + "station_pressure": 1015.5, + "time": 1771052400, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 3, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1015.5, + "station_pressure": 1015.1, + "time": 1771056000, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 4, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1771059600, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 29.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 5, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1015.4, + "station_pressure": 1015.0, + "time": 1771063200, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 29.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 6, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 95, + "sea_level_pressure": 1015.8, + "station_pressure": 1015.4, + "time": 1771066800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1016.2, + "station_pressure": 1015.8, + "time": 1771070400, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1771074000, + "uv": 2.0, + "wind_avg": 19.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1016.6, + "station_pressure": 1016.2, + "time": 1771077600, + "uv": 2.0, + "wind_avg": 20.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 31.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1771081200, + "uv": 2.0, + "wind_avg": 21.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 11, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.1, + "station_pressure": 1015.7, + "time": 1771084800, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 12, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1015.4, + "station_pressure": 1015.0, + "time": 1771088400, + "uv": 4.0, + "wind_avg": 22.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 32.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 13, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1014.7, + "station_pressure": 1014.3, + "time": 1771092000, + "uv": 4.0, + "wind_avg": 22.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 33.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 14, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771095600, + "uv": 3.0, + "wind_avg": 22.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 33.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 15, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771099200, + "uv": 3.0, + "wind_avg": 22.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 16, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.9, + "station_pressure": 1013.5, + "time": 1771102800, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 17, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771106400, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 18, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771110000, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 19, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 15, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.2, + "station_pressure": 1012.8, + "time": 1771113600, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 20, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1013.3, + "station_pressure": 1012.9, + "time": 1771117200, + "uv": 0.0, + "wind_avg": 21.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 21, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771120800, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 22, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1771124400, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 23, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771128000, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 0, + "precip": 0.04, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771131600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771135200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1014.1, + "station_pressure": 1013.7, + "time": 1771138800, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771142400, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1014.3, + "station_pressure": 1013.9, + "time": 1771146000, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771149600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1014.5, + "station_pressure": 1014.1, + "time": 1771153200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1014.6, + "station_pressure": 1014.2, + "time": 1771156800, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 8, + "precip": 0.04, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771160400, + "uv": 4.0, + "wind_avg": 19.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 9, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771164000, + "uv": 4.0, + "wind_avg": 19.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 10, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771167600, + "uv": 4.0, + "wind_avg": 20.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 11, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771171200, + "uv": 4.0, + "wind_avg": 20.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 12, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.5, + "station_pressure": 1013.1, + "time": 1771174800, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 13, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 70, + "sea_level_pressure": 1013.3, + "station_pressure": 1012.9, + "time": 1771178400, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 14, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771182000, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 15, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.5, + "station_pressure": 1013.1, + "time": 1771185600, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 16, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1771189200, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 17, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771192800, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 18, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771196400, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 19, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771200000, + "uv": 3.0, + "wind_avg": 19.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 20, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771203600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 21, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771207200, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 22, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771210800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 23, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1014.6, + "station_pressure": 1014.2, + "time": 1771214400, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + } + ] + }, + "latitude": 29.05592, + "location_name": "OG Pergola", + "longitude": -80.90748, + "source_id_conditions": 5, + "station": { "agl": 1.8288, "elevation": 3.0345869064331055, "is_station_online": true, "state": 1, "station_id": 151283 }, + "status": { "status_code": 0, "status_message": "SUCCESS" }, + "timezone": "America/New_York", + "timezone_offset_minutes": -300, + "units": { "units_air_density": "kg/m3", "units_brightness": "lux", "units_distance": "km", "units_other": "metric", "units_precip": "mm", "units_pressure": "mb", "units_solar_radiation": "w/m2", "units_temp": "c", "units_wind": "kph" } +} diff --git a/tests/mocks/weather_weathergov_current.json b/tests/mocks/weather_weathergov_current.json new file mode 100644 index 0000000000..770bba1ff8 --- /dev/null +++ b/tests/mocks/weather_weathergov_current.json @@ -0,0 +1,151 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "id": "https://api.weather.gov/stations/KDCA/observations/2026-02-06T21:30:00+00:00", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.03, 38.85] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDCA/observations/2026-02-06T21:30:00+00:00", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 20 + }, + "station": "https://api.weather.gov/stations/KDCA", + "stationId": "KDCA", + "stationName": "Washington/Reagan National Airport, DC", + "timestamp": "2026-02-06T21:30:00+00:00", + "rawMessage": "", + "textDescription": "Light Snow", + "icon": "https://api.weather.gov/icons/land/day/snow?size=medium", + "presentWeather": [ + { + "intensity": "light", + "modifier": null, + "weather": "snow", + "rawString": "-SN" + } + ], + "temperature": { + "unitCode": "wmoUnit:degC", + "value": -1, + "qualityControl": "V" + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7, + "qualityControl": "V" + }, + "windDirection": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 0, + "qualityControl": "V" + }, + "windSpeed": { + "unitCode": "wmoUnit:km_h-1", + "value": 0, + "qualityControl": "V" + }, + "windGust": { + "unitCode": "wmoUnit:km_h-1", + "value": null, + "qualityControl": "Z" + }, + "barometricPressure": { + "unitCode": "wmoUnit:Pa", + "value": 100372.55, + "qualityControl": "V" + }, + "seaLevelPressure": { + "unitCode": "wmoUnit:Pa", + "value": null, + "qualityControl": "Z" + }, + "visibility": { + "unitCode": "wmoUnit:m", + "value": 9656.06, + "qualityControl": "C" + }, + "maxTemperatureLast24Hours": { + "unitCode": "wmoUnit:degC", + "value": null + }, + "minTemperatureLast24Hours": { + "unitCode": "wmoUnit:degC", + "value": null + }, + "precipitationLast3Hours": { + "unitCode": "wmoUnit:mm", + "value": null, + "qualityControl": "Z" + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63.771213893297, + "qualityControl": "V" + }, + "windChill": { + "unitCode": "wmoUnit:degC", + "value": null, + "qualityControl": "V" + }, + "heatIndex": { + "unitCode": "wmoUnit:degC", + "value": null, + "qualityControl": "V" + }, + "cloudLayers": [ + { + "base": { + "unitCode": "wmoUnit:m", + "value": 944.88 + }, + "amount": "OVC" + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_forecast.json b/tests/mocks/weather_weathergov_forecast.json new file mode 100644 index 0000000000..258d86532b --- /dev/null +++ b/tests/mocks/weather_weathergov_forecast.json @@ -0,0 +1,304 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.0445, 38.8569], + [-77.0408, 38.8788], + [-77.0689, 38.8818], + [-77.0727, 38.8598], + [-77.0445, 38.8569] + ] + ] + }, + "properties": { + "units": "si", + "forecastGenerator": "BaselineForecastGenerator", + "generatedAt": "2026-02-06T21:45:05+00:00", + "updateTime": "2026-02-06T20:53:00+00:00", + "validTimes": "2026-02-06T14:00:00+00:00/P7DT14H", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.9248 + }, + "periods": [ + { + "number": 1, + "name": "This Afternoon", + "startTime": "2026-02-06T16:00:00-05:00", + "endTime": "2026-02-06T18:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,70?size=medium", + "shortForecast": "Light Snow Likely", + "detailedForecast": "Snow likely. Cloudy, with a high near 1. South wind around 4 km/h. Chance of precipitation is 70%. New snow accumulation of less than one cm possible." + }, + { + "number": 2, + "name": "Tonight", + "startTime": "2026-02-06T18:00:00-05:00", + "endTime": "2026-02-07T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "windSpeed": "2 to 35 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/snow,50/wind_bkn?size=medium", + "shortForecast": "Chance Light Snow then Mostly Cloudy", + "detailedForecast": "A chance of snow before 10pm. Mostly cloudy. Low around -11, with temperatures rising to around -7 overnight. West wind 2 to 35 km/h, with gusts as high as 63 km/h. Chance of precipitation is 50%. New snow accumulation of less than two cm possible." + }, + { + "number": 3, + "name": "Saturday", + "startTime": "2026-02-07T06:00:00-05:00", + "endTime": "2026-02-07T18:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "37 to 48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near -7. Wind chill values as low as -21. Northwest wind 37 to 48 km/h, with gusts as high as 94 km/h." + }, + { + "number": 4, + "name": "Saturday Night", + "startTime": "2026-02-07T18:00:00-05:00", + "endTime": "2026-02-08T06:00:00-05:00", + "isDaytime": false, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "22 to 43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around -12. Wind chill values as low as -21. Northwest wind 22 to 43 km/h, with gusts as high as 76 km/h." + }, + { + "number": 5, + "name": "Sunday", + "startTime": "2026-02-08T06:00:00-05:00", + "endTime": "2026-02-08T18:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "windSpeed": "13 to 22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near -4. Northwest wind 13 to 22 km/h, with gusts as high as 43 km/h." + }, + { + "number": 6, + "name": "Sunday Night", + "startTime": "2026-02-08T18:00:00-05:00", + "endTime": "2026-02-09T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "windSpeed": "4 to 9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=medium", + "shortForecast": "Partly Cloudy", + "detailedForecast": "Partly cloudy, with a low around -11." + }, + { + "number": 7, + "name": "Monday", + "startTime": "2026-02-09T06:00:00-05:00", + "endTime": "2026-02-09T18:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=medium", + "shortForecast": "Partly Sunny", + "detailedForecast": "Partly sunny, with a high near 0." + }, + { + "number": 8, + "name": "Monday Night", + "startTime": "2026-02-09T18:00:00-05:00", + "endTime": "2026-02-10T06:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=medium", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "Mostly cloudy, with a low around -6." + }, + { + "number": 9, + "name": "Tuesday", + "startTime": "2026-02-10T06:00:00-05:00", + "endTime": "2026-02-10T18:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near 7." + }, + { + "number": 10, + "name": "Tuesday Night", + "startTime": "2026-02-10T18:00:00-05:00", + "endTime": "2026-02-11T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn/rain,20?size=medium", + "shortForecast": "Mostly Cloudy then Slight Chance Light Rain", + "detailedForecast": "A slight chance of rain after 1am. Mostly cloudy, with a low around -1." + }, + { + "number": 11, + "name": "Wednesday", + "startTime": "2026-02-11T06:00:00-05:00", + "endTime": "2026-02-11T18:00:00-05:00", + "isDaytime": true, + "temperature": 8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "4 to 11 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain. Mostly cloudy, with a high near 8. Chance of precipitation is 50%." + }, + { + "number": 12, + "name": "Wednesday Night", + "startTime": "2026-02-11T18:00:00-05:00", + "endTime": "2026-02-12T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,50/rain,30?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain. Mostly cloudy, with a low around -1. Chance of precipitation is 50%." + }, + { + "number": 13, + "name": "Thursday", + "startTime": "2026-02-12T06:00:00-05:00", + "endTime": "2026-02-12T18:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "windSpeed": "11 to 17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,30/rain,20?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain before 1pm. Mostly cloudy, with a high near 4. Chance of precipitation is 30%." + }, + { + "number": 14, + "name": "Thursday Night", + "startTime": "2026-02-12T18:00:00-05:00", + "endTime": "2026-02-13T06:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=medium", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "A slight chance of snow after 7pm. Mostly cloudy, with a low around -3." + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_hourly.json b/tests/mocks/weather_weathergov_hourly.json new file mode 100644 index 0000000000..e4fc3bb862 --- /dev/null +++ b/tests/mocks/weather_weathergov_hourly.json @@ -0,0 +1,4250 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.0445, 38.8569], + [-77.0408, 38.8788], + [-77.0689, 38.8818], + [-77.0727, 38.8598], + [-77.0445, 38.8569] + ] + ] + }, + "properties": { + "units": "si", + "forecastGenerator": "HourlyForecastGenerator", + "generatedAt": "2026-02-06T21:45:06+00:00", + "updateTime": "2026-02-06T20:53:00+00:00", + "validTimes": "2026-02-06T14:00:00+00:00/P7DT14H", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.9248 + }, + "periods": [ + { + "number": 1, + "name": "", + "startTime": "2026-02-06T16:00:00-05:00", + "endTime": "2026-02-06T17:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 72 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,70?size=small", + "shortForecast": "Light Snow Likely", + "detailedForecast": "" + }, + { + "number": 2, + "name": "", + "startTime": "2026-02-06T17:00:00-05:00", + "endTime": "2026-02-06T18:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,60?size=small", + "shortForecast": "Light Snow Likely", + "detailedForecast": "" + }, + { + "number": 3, + "name": "", + "startTime": "2026-02-06T18:00:00-05:00", + "endTime": "2026-02-06T19:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/snow,50?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 4, + "name": "", + "startTime": "2026-02-06T19:00:00-05:00", + "endTime": "2026-02-06T20:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 37 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/snow,40?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 5, + "name": "", + "startTime": "2026-02-06T20:00:00-05:00", + "endTime": "2026-02-06T21:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 30 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/night/snow,30?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 6, + "name": "", + "startTime": "2026-02-06T21:00:00-05:00", + "endTime": "2026-02-06T22:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 17 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 7, + "name": "", + "startTime": "2026-02-06T22:00:00-05:00", + "endTime": "2026-02-06T23:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 13 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 8, + "name": "", + "startTime": "2026-02-06T23:00:00-05:00", + "endTime": "2026-02-07T00:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 5 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 9, + "name": "", + "startTime": "2026-02-07T00:00:00-05:00", + "endTime": "2026-02-07T01:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 7 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 10, + "name": "", + "startTime": "2026-02-07T01:00:00-05:00", + "endTime": "2026-02-07T02:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 11, + "name": "", + "startTime": "2026-02-07T02:00:00-05:00", + "endTime": "2026-02-07T03:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 4 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 12, + "name": "", + "startTime": "2026-02-07T03:00:00-05:00", + "endTime": "2026-02-07T04:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 4 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.777777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 13, + "name": "", + "startTime": "2026-02-07T04:00:00-05:00", + "endTime": "2026-02-07T05:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "31 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 14, + "name": "", + "startTime": "2026-02-07T05:00:00-05:00", + "endTime": "2026-02-07T06:00:00-05:00", + "isDaytime": false, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "35 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 15, + "name": "", + "startTime": "2026-02-07T06:00:00-05:00", + "endTime": "2026-02-07T07:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "37 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 16, + "name": "", + "startTime": "2026-02-07T07:00:00-05:00", + "endTime": "2026-02-07T08:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "39 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 17, + "name": "", + "startTime": "2026-02-07T08:00:00-05:00", + "endTime": "2026-02-07T09:00:00-05:00", + "isDaytime": true, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 18, + "name": "", + "startTime": "2026-02-07T09:00:00-05:00", + "endTime": "2026-02-07T10:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "44 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 19, + "name": "", + "startTime": "2026-02-07T10:00:00-05:00", + "endTime": "2026-02-07T11:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 20, + "name": "", + "startTime": "2026-02-07T11:00:00-05:00", + "endTime": "2026-02-07T12:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 21, + "name": "", + "startTime": "2026-02-07T12:00:00-05:00", + "endTime": "2026-02-07T13:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 22, + "name": "", + "startTime": "2026-02-07T13:00:00-05:00", + "endTime": "2026-02-07T14:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 46 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 23, + "name": "", + "startTime": "2026-02-07T14:00:00-05:00", + "endTime": "2026-02-07T15:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "46 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 24, + "name": "", + "startTime": "2026-02-07T15:00:00-05:00", + "endTime": "2026-02-07T16:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 25, + "name": "", + "startTime": "2026-02-07T16:00:00-05:00", + "endTime": "2026-02-07T17:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "41 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 26, + "name": "", + "startTime": "2026-02-07T17:00:00-05:00", + "endTime": "2026-02-07T18:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "41 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 27, + "name": "", + "startTime": "2026-02-07T18:00:00-05:00", + "endTime": "2026-02-07T19:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 46 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 28, + "name": "", + "startTime": "2026-02-07T19:00:00-05:00", + "endTime": "2026-02-07T20:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 29, + "name": "", + "startTime": "2026-02-07T20:00:00-05:00", + "endTime": "2026-02-07T21:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 51 + }, + "windSpeed": "39 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 30, + "name": "", + "startTime": "2026-02-07T21:00:00-05:00", + "endTime": "2026-02-07T22:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "37 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 31, + "name": "", + "startTime": "2026-02-07T22:00:00-05:00", + "endTime": "2026-02-07T23:00:00-05:00", + "isDaytime": false, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "33 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 32, + "name": "", + "startTime": "2026-02-07T23:00:00-05:00", + "endTime": "2026-02-08T00:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "31 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 33, + "name": "", + "startTime": "2026-02-08T00:00:00-05:00", + "endTime": "2026-02-08T01:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "28 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 34, + "name": "", + "startTime": "2026-02-08T01:00:00-05:00", + "endTime": "2026-02-08T02:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "28 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 35, + "name": "", + "startTime": "2026-02-08T02:00:00-05:00", + "endTime": "2026-02-08T03:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "26 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 36, + "name": "", + "startTime": "2026-02-08T03:00:00-05:00", + "endTime": "2026-02-08T04:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 37, + "name": "", + "startTime": "2026-02-08T04:00:00-05:00", + "endTime": "2026-02-08T05:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 38, + "name": "", + "startTime": "2026-02-08T05:00:00-05:00", + "endTime": "2026-02-08T06:00:00-05:00", + "isDaytime": false, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 60 + }, + "windSpeed": "22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 39, + "name": "", + "startTime": "2026-02-08T06:00:00-05:00", + "endTime": "2026-02-08T07:00:00-05:00", + "isDaytime": true, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 40, + "name": "", + "startTime": "2026-02-08T07:00:00-05:00", + "endTime": "2026-02-08T08:00:00-05:00", + "isDaytime": true, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "20 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 41, + "name": "", + "startTime": "2026-02-08T08:00:00-05:00", + "endTime": "2026-02-08T09:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "20 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 42, + "name": "", + "startTime": "2026-02-08T09:00:00-05:00", + "endTime": "2026-02-08T10:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 43, + "name": "", + "startTime": "2026-02-08T10:00:00-05:00", + "endTime": "2026-02-08T11:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.666666666666668 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 51 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 44, + "name": "", + "startTime": "2026-02-08T11:00:00-05:00", + "endTime": "2026-02-08T12:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 45, + "name": "", + "startTime": "2026-02-08T12:00:00-05:00", + "endTime": "2026-02-08T13:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 46, + "name": "", + "startTime": "2026-02-08T13:00:00-05:00", + "endTime": "2026-02-08T14:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 47, + "name": "", + "startTime": "2026-02-08T14:00:00-05:00", + "endTime": "2026-02-08T15:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 48, + "name": "", + "startTime": "2026-02-08T15:00:00-05:00", + "endTime": "2026-02-08T16:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 49, + "name": "", + "startTime": "2026-02-08T16:00:00-05:00", + "endTime": "2026-02-08T17:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 50, + "name": "", + "startTime": "2026-02-08T17:00:00-05:00", + "endTime": "2026-02-08T18:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 51, + "name": "", + "startTime": "2026-02-08T18:00:00-05:00", + "endTime": "2026-02-08T19:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 52, + "name": "", + "startTime": "2026-02-08T19:00:00-05:00", + "endTime": "2026-02-08T20:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 53, + "name": "", + "startTime": "2026-02-08T20:00:00-05:00", + "endTime": "2026-02-08T21:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 56 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 54, + "name": "", + "startTime": "2026-02-08T21:00:00-05:00", + "endTime": "2026-02-08T22:00:00-05:00", + "isDaytime": false, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 55, + "name": "", + "startTime": "2026-02-08T22:00:00-05:00", + "endTime": "2026-02-08T23:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 56, + "name": "", + "startTime": "2026-02-08T23:00:00-05:00", + "endTime": "2026-02-09T00:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 57, + "name": "", + "startTime": "2026-02-09T00:00:00-05:00", + "endTime": "2026-02-09T01:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 58, + "name": "", + "startTime": "2026-02-09T01:00:00-05:00", + "endTime": "2026-02-09T02:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 59, + "name": "", + "startTime": "2026-02-09T02:00:00-05:00", + "endTime": "2026-02-09T03:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 60, + "name": "", + "startTime": "2026-02-09T03:00:00-05:00", + "endTime": "2026-02-09T04:00:00-05:00", + "isDaytime": false, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 61, + "name": "", + "startTime": "2026-02-09T04:00:00-05:00", + "endTime": "2026-02-09T05:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 62, + "name": "", + "startTime": "2026-02-09T05:00:00-05:00", + "endTime": "2026-02-09T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 63, + "name": "", + "startTime": "2026-02-09T06:00:00-05:00", + "endTime": "2026-02-09T07:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 64, + "name": "", + "startTime": "2026-02-09T07:00:00-05:00", + "endTime": "2026-02-09T08:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 65, + "name": "", + "startTime": "2026-02-09T08:00:00-05:00", + "endTime": "2026-02-09T09:00:00-05:00", + "isDaytime": true, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 66, + "name": "", + "startTime": "2026-02-09T09:00:00-05:00", + "endTime": "2026-02-09T10:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 67, + "name": "", + "startTime": "2026-02-09T10:00:00-05:00", + "endTime": "2026-02-09T11:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 56 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 68, + "name": "", + "startTime": "2026-02-09T11:00:00-05:00", + "endTime": "2026-02-09T12:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -12.777777777777779 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 69, + "name": "", + "startTime": "2026-02-09T12:00:00-05:00", + "endTime": "2026-02-09T13:00:00-05:00", + "isDaytime": true, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.666666666666666 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 70, + "name": "", + "startTime": "2026-02-09T13:00:00-05:00", + "endTime": "2026-02-09T14:00:00-05:00", + "isDaytime": true, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 71, + "name": "", + "startTime": "2026-02-09T14:00:00-05:00", + "endTime": "2026-02-09T15:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 72, + "name": "", + "startTime": "2026-02-09T15:00:00-05:00", + "endTime": "2026-02-09T16:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 73, + "name": "", + "startTime": "2026-02-09T16:00:00-05:00", + "endTime": "2026-02-09T17:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 74, + "name": "", + "startTime": "2026-02-09T17:00:00-05:00", + "endTime": "2026-02-09T18:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 75, + "name": "", + "startTime": "2026-02-09T18:00:00-05:00", + "endTime": "2026-02-09T19:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 76, + "name": "", + "startTime": "2026-02-09T19:00:00-05:00", + "endTime": "2026-02-09T20:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 77, + "name": "", + "startTime": "2026-02-09T20:00:00-05:00", + "endTime": "2026-02-09T21:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 60 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 78, + "name": "", + "startTime": "2026-02-09T21:00:00-05:00", + "endTime": "2026-02-09T22:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 65 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 79, + "name": "", + "startTime": "2026-02-09T22:00:00-05:00", + "endTime": "2026-02-09T23:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 80, + "name": "", + "startTime": "2026-02-09T23:00:00-05:00", + "endTime": "2026-02-10T00:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 81, + "name": "", + "startTime": "2026-02-10T00:00:00-05:00", + "endTime": "2026-02-10T01:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "2 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 82, + "name": "", + "startTime": "2026-02-10T01:00:00-05:00", + "endTime": "2026-02-10T02:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 83, + "name": "", + "startTime": "2026-02-10T02:00:00-05:00", + "endTime": "2026-02-10T03:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 84, + "name": "", + "startTime": "2026-02-10T03:00:00-05:00", + "endTime": "2026-02-10T04:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 85, + "name": "", + "startTime": "2026-02-10T04:00:00-05:00", + "endTime": "2026-02-10T05:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 86, + "name": "", + "startTime": "2026-02-10T05:00:00-05:00", + "endTime": "2026-02-10T06:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 87, + "name": "", + "startTime": "2026-02-10T06:00:00-05:00", + "endTime": "2026-02-10T07:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 88, + "name": "", + "startTime": "2026-02-10T07:00:00-05:00", + "endTime": "2026-02-10T08:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 89, + "name": "", + "startTime": "2026-02-10T08:00:00-05:00", + "endTime": "2026-02-10T09:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.777777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 90, + "name": "", + "startTime": "2026-02-10T09:00:00-05:00", + "endTime": "2026-02-10T10:00:00-05:00", + "isDaytime": true, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "2 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 91, + "name": "", + "startTime": "2026-02-10T10:00:00-05:00", + "endTime": "2026-02-10T11:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 92, + "name": "", + "startTime": "2026-02-10T11:00:00-05:00", + "endTime": "2026-02-10T12:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 93, + "name": "", + "startTime": "2026-02-10T12:00:00-05:00", + "endTime": "2026-02-10T13:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 94, + "name": "", + "startTime": "2026-02-10T13:00:00-05:00", + "endTime": "2026-02-10T14:00:00-05:00", + "isDaytime": true, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 95, + "name": "", + "startTime": "2026-02-10T14:00:00-05:00", + "endTime": "2026-02-10T15:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 96, + "name": "", + "startTime": "2026-02-10T15:00:00-05:00", + "endTime": "2026-02-10T16:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 97, + "name": "", + "startTime": "2026-02-10T16:00:00-05:00", + "endTime": "2026-02-10T17:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "4 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 98, + "name": "", + "startTime": "2026-02-10T17:00:00-05:00", + "endTime": "2026-02-10T18:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 99, + "name": "", + "startTime": "2026-02-10T18:00:00-05:00", + "endTime": "2026-02-10T19:00:00-05:00", + "isDaytime": false, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 100, + "name": "", + "startTime": "2026-02-10T19:00:00-05:00", + "endTime": "2026-02-10T20:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 101, + "name": "", + "startTime": "2026-02-10T20:00:00-05:00", + "endTime": "2026-02-10T21:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "2 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 102, + "name": "", + "startTime": "2026-02-10T21:00:00-05:00", + "endTime": "2026-02-10T22:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 103, + "name": "", + "startTime": "2026-02-10T22:00:00-05:00", + "endTime": "2026-02-10T23:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 104, + "name": "", + "startTime": "2026-02-10T23:00:00-05:00", + "endTime": "2026-02-11T00:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 105, + "name": "", + "startTime": "2026-02-11T00:00:00-05:00", + "endTime": "2026-02-11T01:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "2 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 106, + "name": "", + "startTime": "2026-02-11T01:00:00-05:00", + "endTime": "2026-02-11T02:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 107, + "name": "", + "startTime": "2026-02-11T02:00:00-05:00", + "endTime": "2026-02-11T03:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 108, + "name": "", + "startTime": "2026-02-11T03:00:00-05:00", + "endTime": "2026-02-11T04:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 109, + "name": "", + "startTime": "2026-02-11T04:00:00-05:00", + "endTime": "2026-02-11T05:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 88 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 110, + "name": "", + "startTime": "2026-02-11T05:00:00-05:00", + "endTime": "2026-02-11T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 88 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 111, + "name": "", + "startTime": "2026-02-11T06:00:00-05:00", + "endTime": "2026-02-11T07:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 112, + "name": "", + "startTime": "2026-02-11T07:00:00-05:00", + "endTime": "2026-02-11T08:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 113, + "name": "", + "startTime": "2026-02-11T08:00:00-05:00", + "endTime": "2026-02-11T09:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 114, + "name": "", + "startTime": "2026-02-11T09:00:00-05:00", + "endTime": "2026-02-11T10:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 115, + "name": "", + "startTime": "2026-02-11T10:00:00-05:00", + "endTime": "2026-02-11T11:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 82 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 116, + "name": "", + "startTime": "2026-02-11T11:00:00-05:00", + "endTime": "2026-02-11T12:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 79 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 117, + "name": "", + "startTime": "2026-02-11T12:00:00-05:00", + "endTime": "2026-02-11T13:00:00-05:00", + "isDaytime": true, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 76 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 118, + "name": "", + "startTime": "2026-02-11T13:00:00-05:00", + "endTime": "2026-02-11T14:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 119, + "name": "", + "startTime": "2026-02-11T14:00:00-05:00", + "endTime": "2026-02-11T15:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 120, + "name": "", + "startTime": "2026-02-11T15:00:00-05:00", + "endTime": "2026-02-11T16:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 121, + "name": "", + "startTime": "2026-02-11T16:00:00-05:00", + "endTime": "2026-02-11T17:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 122, + "name": "", + "startTime": "2026-02-11T17:00:00-05:00", + "endTime": "2026-02-11T18:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 123, + "name": "", + "startTime": "2026-02-11T18:00:00-05:00", + "endTime": "2026-02-11T19:00:00-05:00", + "isDaytime": false, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 124, + "name": "", + "startTime": "2026-02-11T19:00:00-05:00", + "endTime": "2026-02-11T20:00:00-05:00", + "isDaytime": false, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 125, + "name": "", + "startTime": "2026-02-11T20:00:00-05:00", + "endTime": "2026-02-11T21:00:00-05:00", + "isDaytime": false, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 126, + "name": "", + "startTime": "2026-02-11T21:00:00-05:00", + "endTime": "2026-02-11T22:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 76 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 127, + "name": "", + "startTime": "2026-02-11T22:00:00-05:00", + "endTime": "2026-02-11T23:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 128, + "name": "", + "startTime": "2026-02-11T23:00:00-05:00", + "endTime": "2026-02-12T00:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 129, + "name": "", + "startTime": "2026-02-12T00:00:00-05:00", + "endTime": "2026-02-12T01:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 130, + "name": "", + "startTime": "2026-02-12T01:00:00-05:00", + "endTime": "2026-02-12T02:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 131, + "name": "", + "startTime": "2026-02-12T02:00:00-05:00", + "endTime": "2026-02-12T03:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 132, + "name": "", + "startTime": "2026-02-12T03:00:00-05:00", + "endTime": "2026-02-12T04:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 133, + "name": "", + "startTime": "2026-02-12T04:00:00-05:00", + "endTime": "2026-02-12T05:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 134, + "name": "", + "startTime": "2026-02-12T05:00:00-05:00", + "endTime": "2026-02-12T06:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 135, + "name": "", + "startTime": "2026-02-12T06:00:00-05:00", + "endTime": "2026-02-12T07:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 136, + "name": "", + "startTime": "2026-02-12T07:00:00-05:00", + "endTime": "2026-02-12T08:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 137, + "name": "", + "startTime": "2026-02-12T08:00:00-05:00", + "endTime": "2026-02-12T09:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 138, + "name": "", + "startTime": "2026-02-12T09:00:00-05:00", + "endTime": "2026-02-12T10:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 72 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 139, + "name": "", + "startTime": "2026-02-12T10:00:00-05:00", + "endTime": "2026-02-12T11:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 140, + "name": "", + "startTime": "2026-02-12T11:00:00-05:00", + "endTime": "2026-02-12T12:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 141, + "name": "", + "startTime": "2026-02-12T12:00:00-05:00", + "endTime": "2026-02-12T13:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 142, + "name": "", + "startTime": "2026-02-12T13:00:00-05:00", + "endTime": "2026-02-12T14:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 143, + "name": "", + "startTime": "2026-02-12T14:00:00-05:00", + "endTime": "2026-02-12T15:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 144, + "name": "", + "startTime": "2026-02-12T15:00:00-05:00", + "endTime": "2026-02-12T16:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 145, + "name": "", + "startTime": "2026-02-12T16:00:00-05:00", + "endTime": "2026-02-12T17:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 146, + "name": "", + "startTime": "2026-02-12T17:00:00-05:00", + "endTime": "2026-02-12T18:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 147, + "name": "", + "startTime": "2026-02-12T18:00:00-05:00", + "endTime": "2026-02-12T19:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 148, + "name": "", + "startTime": "2026-02-12T19:00:00-05:00", + "endTime": "2026-02-12T20:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 149, + "name": "", + "startTime": "2026-02-12T20:00:00-05:00", + "endTime": "2026-02-12T21:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 150, + "name": "", + "startTime": "2026-02-12T21:00:00-05:00", + "endTime": "2026-02-12T22:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 151, + "name": "", + "startTime": "2026-02-12T22:00:00-05:00", + "endTime": "2026-02-12T23:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 66 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 152, + "name": "", + "startTime": "2026-02-12T23:00:00-05:00", + "endTime": "2026-02-13T00:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 153, + "name": "", + "startTime": "2026-02-13T00:00:00-05:00", + "endTime": "2026-02-13T01:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 66 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 154, + "name": "", + "startTime": "2026-02-13T01:00:00-05:00", + "endTime": "2026-02-13T02:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 155, + "name": "", + "startTime": "2026-02-13T02:00:00-05:00", + "endTime": "2026-02-13T03:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 156, + "name": "", + "startTime": "2026-02-13T03:00:00-05:00", + "endTime": "2026-02-13T04:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.222222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_points.json b/tests/mocks/weather_weathergov_points.json new file mode 100644 index 0000000000..d13794899f --- /dev/null +++ b/tests/mocks/weather_weathergov_points.json @@ -0,0 +1,89 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "id": "https://api.weather.gov/points/38.8894,-77.0352", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.0352, 38.8894] + }, + "properties": { + "@id": "https://api.weather.gov/points/38.8894,-77.0352", + "@type": "wx:Point", + "cwa": "LWX", + "type": "land", + "forecastOffice": "https://api.weather.gov/offices/LWX", + "gridId": "LWX", + "gridX": 97, + "gridY": 71, + "forecast": "https://api.weather.gov/gridpoints/LWX/97,71/forecast", + "forecastHourly": "https://api.weather.gov/gridpoints/LWX/97,71/forecast/hourly", + "forecastGridData": "https://api.weather.gov/gridpoints/LWX/97,71", + "observationStations": "https://api.weather.gov/gridpoints/LWX/97,71/stations", + "relativeLocation": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.017229, 38.904103] + }, + "properties": { + "city": "Washington", + "state": "DC", + "distance": { + "unitCode": "wmoUnit:m", + "value": 2256.4628420106 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 223 + } + } + }, + "forecastZone": "https://api.weather.gov/zones/forecast/DCZ001", + "county": "https://api.weather.gov/zones/county/DCC001", + "fireWeatherZone": "https://api.weather.gov/zones/fire/DCZ001", + "timeZone": "America/New_York", + "radarStation": "KLWX" + } +} diff --git a/tests/mocks/weather_weathergov_stations.json b/tests/mocks/weather_weathergov_stations.json new file mode 100644 index 0000000000..742524a693 --- /dev/null +++ b/tests/mocks/weather_weathergov_stations.json @@ -0,0 +1,1793 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + }, + "observationStations": { + "@container": "@list", + "@type": "@id" + } + } + ], + "type": "FeatureCollection", + "features": [ + { + "id": "https://api.weather.gov/stations/KDCA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.03417, 38.84833] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDCA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 3.9624 + }, + "stationIdentifier": "KDCA", + "name": "Washington/Reagan National Airport, DC", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 3043.6748842539 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 140 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ054", + "county": "https://api.weather.gov/zones/county/VAC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ054" + } + }, + { + "id": "https://api.weather.gov/stations/KCGS", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.9223, 38.9806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCGS", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KCGS", + "name": "College Park Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 16980.874107362 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ013", + "county": "https://api.weather.gov/zones/county/MDC033", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ013" + } + }, + { + "id": "https://api.weather.gov/stations/KADW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.85, 38.81667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KADW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 85.9536 + }, + "stationIdentifier": "KADW", + "name": "Camp Springs / Andrews Air Force Base", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 18837.139622535 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 108 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ013", + "county": "https://api.weather.gov/zones/county/MDC033", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ013" + } + }, + { + "id": "https://api.weather.gov/stations/KDAA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.18333, 38.71667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDAA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.0312 + }, + "stationIdentifier": "KDAA", + "name": "Fort Belvoir", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 20211.268967046 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 212 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ053", + "county": "https://api.weather.gov/zones/county/VAC059", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ053" + } + }, + { + "id": "https://api.weather.gov/stations/KFME", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.76667, 39.08333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFME", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 46.0248 + }, + "stationIdentifier": "KFME", + "name": "Fort Meade / Tipton", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34568.722953871 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 46 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KIAD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.4475, 38.93472] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KIAD", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 95.0976 + }, + "stationIdentifier": "KIAD", + "name": "Washington/Dulles International Airport, DC", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34587.939860418 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 282 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ053", + "county": "https://api.weather.gov/zones/county/VAC059", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ053" + } + }, + { + "id": "https://api.weather.gov/stations/KGAI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.16551, 39.16957] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KGAI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 150.876 + }, + "stationIdentifier": "KGAI", + "name": "Gaithersburg - Montgomery County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34683.691088332 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 344 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ504", + "county": "https://api.weather.gov/zones/county/MDC031", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ504" + } + }, + { + "id": "https://api.weather.gov/stations/KHEF", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.51667, 38.71667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHEF", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 59.1312 + }, + "stationIdentifier": "KHEF", + "name": "Manassas, Manassas Regional Airport/Harry P. Davis Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 43324.86402354 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ527", + "county": "https://api.weather.gov/zones/county/VAC683", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ527" + } + }, + { + "id": "https://api.weather.gov/stations/KNYG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.30129, 38.50326] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNYG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 2.1336 + }, + "stationIdentifier": "KNYG", + "name": "Quantico Marine Corps Airfield - Turner Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 45906.349012092 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 207 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ527", + "county": "https://api.weather.gov/zones/county/VAC153", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ527" + } + }, + { + "id": "https://api.weather.gov/stations/KBWI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.68404, 39.17329] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KBWI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 41.148 + }, + "stationIdentifier": "KBWI", + "name": "Baltimore, Baltimore-Washington International Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 46680.187081868 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KJYO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.56667, 39.08333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KJYO", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 118.872 + }, + "stationIdentifier": "KJYO", + "name": "Leesburg / Godfrey", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 50093.979211268 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 298 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ506", + "county": "https://api.weather.gov/zones/county/VAC107", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ506" + } + }, + { + "id": "https://api.weather.gov/stations/KNAK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.48907, 38.99125] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNAK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 0.9144 + }, + "stationIdentifier": "KNAK", + "name": "Annapolis, United States Naval Academy", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 50940.029746488 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 74 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KDMH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.61667, 39.28333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDMH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 6.096 + }, + "stationIdentifier": "KDMH", + "name": "Baltimore, Inner Harbor", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 59684.848549395 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 39 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ011", + "county": "https://api.weather.gov/zones/county/MDC510", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ011" + } + }, + { + "id": "https://api.weather.gov/stations/KRMN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.45528, 38.39806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRMN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 64.9224 + }, + "stationIdentifier": "KRMN", + "name": "Stafford, Stafford Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 62803.929543738 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 213 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ055", + "county": "https://api.weather.gov/zones/county/VAC179", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ055" + } + }, + { + "id": "https://api.weather.gov/stations/KW29", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.33, 38.9767] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KW29", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KW29", + "name": "Bay Bridge Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 63992.315724071 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 79 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ015", + "county": "https://api.weather.gov/zones/county/MDC035", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ015" + } + }, + { + "id": "https://api.weather.gov/stations/KHWY", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.71501, 38.58765] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHWY", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 92.0496 + }, + "stationIdentifier": "KHWY", + "name": "Warrenton-Fauquier Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 65127.84173743 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 241 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ502", + "county": "https://api.weather.gov/zones/county/VAC061", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ502" + } + }, + { + "id": "https://api.weather.gov/stations/KFDK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.36982, 39.41775] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFDK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 81.9912 + }, + "stationIdentifier": "KFDK", + "name": "Frederick Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 66692.582365459 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 336 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ004", + "county": "https://api.weather.gov/zones/county/MDC021", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ004" + } + }, + { + "id": "https://api.weather.gov/stations/KEZF", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.45, 38.26667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEZF", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 25.908 + }, + "stationIdentifier": "KEZF", + "name": "Fredericksburg, Shannon Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75229.907706335 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 207 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ056", + "county": "https://api.weather.gov/zones/county/VAC177", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ056" + } + }, + { + "id": "https://api.weather.gov/stations/KMTN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.41667, 39.33333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMTN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.0104 + }, + "stationIdentifier": "KMTN", + "name": "Baltimore / Martin", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75581.565023524 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 46 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ011", + "county": "https://api.weather.gov/zones/county/MDC005", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ011" + } + }, + { + "id": "https://api.weather.gov/stations/K2W6", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.5501, 38.3154] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2W6", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 43.8912 + }, + "stationIdentifier": "K2W6", + "name": "St Marys County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75712.983389938 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 144 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KCJR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.85738, 38.52607] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCJR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 89.916 + }, + "stationIdentifier": "KCJR", + "name": "Culpeper Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 79274.931842245 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 241 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ051", + "county": "https://api.weather.gov/zones/county/VAC047", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ051" + } + }, + { + "id": "https://api.weather.gov/stations/KDMW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.0077, 39.6083] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDMW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 240.4872 + }, + "stationIdentifier": "KDMW", + "name": "Carroll County Regional Jack B Poage Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 82279.382252508 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 2 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ005", + "county": "https://api.weather.gov/zones/county/MDC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ005" + } + }, + { + "id": "https://api.weather.gov/stations/KESN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.06667, 38.8] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KESN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.9456 + }, + "stationIdentifier": "KESN", + "name": "Easton Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 86100.892604455 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 94 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ019", + "county": "https://api.weather.gov/zones/county/MDC041", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ019" + } + }, + { + "id": "https://api.weather.gov/stations/KNHK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.41389, 38.27861] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNHK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 11.8872 + }, + "stationIdentifier": "KNHK", + "name": "Patuxent River, Naval Air Station", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 86239.928763087 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 139 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KRSP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.468, 39.645] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRSP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 561.1368 + }, + "stationIdentifier": "KRSP", + "name": "Camp David", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 93237.282967055 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 337 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ004", + "county": "https://api.weather.gov/zones/county/MDC021", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ004" + } + }, + { + "id": "https://api.weather.gov/stations/KNUI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.42, 38.14889] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNUI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 6.096 + }, + "stationIdentifier": "KNUI", + "name": "St. Inigoes, Webster Field, Naval Electronic Systems Engineering Activity", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 97399.572233461 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 145 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KMRB", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.975, 39.40372] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMRB", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 163.9824 + }, + "stationIdentifier": "KMRB", + "name": "Eastern WV Regional Airport/Shepherd Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 99011.527250549 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 307 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ052", + "county": "https://api.weather.gov/zones/county/WVC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ052" + } + }, + { + "id": "https://api.weather.gov/stations/KOKV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.15, 39.15] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOKV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 221.8944 + }, + "stationIdentifier": "KOKV", + "name": "Winchester Regional", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 99483.258804184 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 288 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ028", + "county": "https://api.weather.gov/zones/county/VAC069", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ028" + } + }, + { + "id": "https://api.weather.gov/stations/KAPG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.16667, 39.46667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KAPG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 17.9832 + }, + "stationIdentifier": "KAPG", + "name": "Phillips Army Air Field / Aberdeen", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 101486.28902381 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 48 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ508", + "county": "https://api.weather.gov/zones/county/MDC025", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ508" + } + }, + { + "id": "https://api.weather.gov/stations/KFRR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.2535, 38.9175] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFRR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 216.1032 + }, + "stationIdentifier": "KFRR", + "name": "Front Royal-warren County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 103711.79430435 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 273 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ030", + "county": "https://api.weather.gov/zones/county/VAC187", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ030" + } + }, + { + "id": "https://api.weather.gov/stations/K0W3", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.20297, 39.5682] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K0W3", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 125.5776 + }, + "stationIdentifier": "K0W3", + "name": "Harford County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 106997.11216766 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ508", + "county": "https://api.weather.gov/zones/county/MDC025", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ508" + } + }, + { + "id": "https://api.weather.gov/stations/KHGR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.73, 39.70583] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHGR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 213.9696 + }, + "stationIdentifier": "KHGR", + "name": "Hagerstown, Washington County Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 109586.26730048 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 328 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ003", + "county": "https://api.weather.gov/zones/county/MDC043", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ003" + } + }, + { + "id": "https://api.weather.gov/stations/KOMH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.04556, 38.24722] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOMH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 142.0368 + }, + "stationIdentifier": "KOMH", + "name": "Orange, Orange County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 110351.40846398 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 231 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ050", + "county": "https://api.weather.gov/zones/county/VAC137", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ050" + } + }, + { + "id": "https://api.weather.gov/stations/K7W4", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.7459, 37.9658] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K7W4", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 106.9848 + }, + "stationIdentifier": "K7W4", + "name": "Lake Anna Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 117039.9799578 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 211 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ510", + "county": "https://api.weather.gov/zones/county/VAC109", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ510" + } + }, + { + "id": "https://api.weather.gov/stations/KTHV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.87694, 39.91944] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KTHV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 145.9992 + }, + "stationIdentifier": "KTHV", + "name": "York, York Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 117785.75882145 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 7 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ065", + "county": "https://api.weather.gov/zones/county/PAC133", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ065" + } + }, + { + "id": "https://api.weather.gov/stations/KLKU", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.97028, 38.00972] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLKU", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 149.9616 + }, + "stationIdentifier": "KLKU", + "name": "Louisa, Louisa County Airport/Freeman Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 124364.21495608 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 220 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ510", + "county": "https://api.weather.gov/zones/county/VAC109", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ510" + } + }, + { + "id": "https://api.weather.gov/stations/KGVE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.1658, 38.156] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KGVE", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 138.0744 + }, + "stationIdentifier": "KGVE", + "name": "Gordonsville Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 124909.61245374 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 230 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ050", + "county": "https://api.weather.gov/zones/county/VAC137", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ050" + } + }, + { + "id": "https://api.weather.gov/stations/KOFP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.43444, 37.70806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOFP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 61.8744 + }, + "stationIdentifier": "KOFP", + "name": "Ashland, Hanover County Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 133267.46930022 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 194 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ511", + "county": "https://api.weather.gov/zones/county/VAC085", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ511" + } + }, + { + "id": "https://api.weather.gov/stations/K8W2", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.7081, 38.6557] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K8W2", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 297.18 + }, + "stationIdentifier": "K8W2", + "name": "New Market Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 145135.20416818 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 261 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ027", + "county": "https://api.weather.gov/zones/county/VAC171", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ027" + } + }, + { + "id": "https://api.weather.gov/stations/KCHO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.45516, 38.13738] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCHO", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 195.072 + }, + "stationIdentifier": "KCHO", + "name": "Charlottesville-Albemarle Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 146394.21605562 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 236 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ037", + "county": "https://api.weather.gov/zones/county/VAC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ037" + } + }, + { + "id": "https://api.weather.gov/stations/KRIC", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.32333, 37.51111] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRIC", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 50.9016 + }, + "stationIdentifier": "KRIC", + "name": "Richmond, Richmond International Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 152812.69388052 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 188 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ516", + "county": "https://api.weather.gov/zones/county/VAC087", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ516" + } + }, + { + "id": "https://api.weather.gov/stations/KILG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.60567, 39.67442] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KILG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.9456 + }, + "stationIdentifier": "KILG", + "name": "Wilmington Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 153674.36438971 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 53 + }, + "forecast": "https://api.weather.gov/zones/forecast/DEZ001", + "county": "https://api.weather.gov/zones/county/DEC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/DEZ001" + } + }, + { + "id": "https://api.weather.gov/stations/KLNS", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.29446, 40.12058] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLNS", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 121.0056 + }, + "stationIdentifier": "KLNS", + "name": "Lancaster, Lancaster Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 153739.95516367 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 24 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ066", + "county": "https://api.weather.gov/zones/county/PAC071", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ066" + } + }, + { + "id": "https://api.weather.gov/stations/KCBE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.76083, 39.61528] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCBE", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 235.9152 + }, + "stationIdentifier": "KCBE", + "name": "Cumberland, Greater Cumberland Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 168568.42779476 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 300 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ504", + "county": "https://api.weather.gov/zones/county/WVC057", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ504" + } + }, + { + "id": "https://api.weather.gov/stations/KSHD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.9, 38.26667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KSHD", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 366.0648 + }, + "stationIdentifier": "KSHD", + "name": "Staunton / Shenandoah", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 173695.8603158 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ025", + "county": "https://api.weather.gov/zones/county/VAC015", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ025" + } + }, + { + "id": "https://api.weather.gov/stations/KVBW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.96033, 38.36674] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KVBW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 355.092 + }, + "stationIdentifier": "KVBW", + "name": "Bridgewater Air Park", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 174565.90990218 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 251 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ026", + "county": "https://api.weather.gov/zones/county/VAC165", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ026" + } + }, + { + "id": "https://api.weather.gov/stations/KMFV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.76667, 37.65] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMFV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KMFV", + "name": "Melfa / Accomack Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 176261.84200174 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 139 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ099", + "county": "https://api.weather.gov/zones/county/VAC001", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ099" + } + }, + { + "id": "https://api.weather.gov/stations/KW13", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.9444, 38.0769] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KW13", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 437.9976 + }, + "stationIdentifier": "KW13", + "name": "Eagles Nest Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 186456.91815645 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 242 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ025", + "county": "https://api.weather.gov/zones/county/VAC015", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ025" + } + }, + { + "id": "https://api.weather.gov/stations/KFVX", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.43333, 37.35] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFVX", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 124.968 + }, + "stationIdentifier": "KFVX", + "name": "Farmville", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 207471.35283371 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 215 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ061", + "county": "https://api.weather.gov/zones/county/VAC049", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ061" + } + }, + { + "id": "https://api.weather.gov/stations/K2G4", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.3394, 39.5803] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2G4", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 893.9784 + }, + "stationIdentifier": "K2G4", + "name": "Garrett County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 211917.76730527 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 292 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ509", + "county": "https://api.weather.gov/zones/county/MDC023", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ509" + } + }, + { + "id": "https://api.weather.gov/stations/K2G9", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.015, 40.0389] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2G9", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 693.42 + }, + "stationIdentifier": "K2G9", + "name": "Somerset County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 212550.3935379 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 308 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ033", + "county": "https://api.weather.gov/zones/county/PAC111", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ033" + } + }, + { + "id": "https://api.weather.gov/stations/KEKN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.85278, 38.88528] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEKN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 605.028 + }, + "stationIdentifier": "KEKN", + "name": "Elkins, Elkins-Randolph County-Jennings Randolph Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 242035.43677875 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 271 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ525", + "county": "https://api.weather.gov/zones/county/WVC083", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ525" + } + }, + { + "id": "https://api.weather.gov/stations/KLYH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.20667, 37.32083] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLYH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 284.988 + }, + "stationIdentifier": "KLYH", + "name": "Lynchburg, Lynchburg Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 255021.94789795 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 228 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ045", + "county": "https://api.weather.gov/zones/county/VAC031", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ045" + } + }, + { + "id": "https://api.weather.gov/stations/KMGW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.92065, 39.64985] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMGW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 373.9896 + }, + "stationIdentifier": "KMGW", + "name": "Morgantown Municipal-Hart Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 261388.09543822 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 290 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ509", + "county": "https://api.weather.gov/zones/county/WVC061", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ509" + } + }, + { + "id": "https://api.weather.gov/stations/KHSP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.83333, 37.95] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHSP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 1156.1064 + }, + "stationIdentifier": "KHSP", + "name": "Hot Springs / Ingalls", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 262623.28570565 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ020", + "county": "https://api.weather.gov/zones/county/VAC017", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ020" + } + }, + { + "id": "https://api.weather.gov/stations/KROA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.97417, 37.31694] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KROA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 358.14 + }, + "stationIdentifier": "KROA", + "name": "Roanoke, Roanoke Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 308160.42662802 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 236 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ022", + "county": "https://api.weather.gov/zones/county/VAC770", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ022" + } + } + ], + "observationStations": [ + "https://api.weather.gov/stations/KDCA", + "https://api.weather.gov/stations/KCGS", + "https://api.weather.gov/stations/KADW", + "https://api.weather.gov/stations/KDAA", + "https://api.weather.gov/stations/KFME", + "https://api.weather.gov/stations/KIAD", + "https://api.weather.gov/stations/KGAI", + "https://api.weather.gov/stations/KHEF", + "https://api.weather.gov/stations/KNYG", + "https://api.weather.gov/stations/KBWI", + "https://api.weather.gov/stations/KJYO", + "https://api.weather.gov/stations/KNAK", + "https://api.weather.gov/stations/KDMH", + "https://api.weather.gov/stations/KRMN", + "https://api.weather.gov/stations/KW29", + "https://api.weather.gov/stations/KHWY", + "https://api.weather.gov/stations/KFDK", + "https://api.weather.gov/stations/KEZF", + "https://api.weather.gov/stations/KMTN", + "https://api.weather.gov/stations/K2W6", + "https://api.weather.gov/stations/KCJR", + "https://api.weather.gov/stations/KDMW", + "https://api.weather.gov/stations/KESN", + "https://api.weather.gov/stations/KNHK", + "https://api.weather.gov/stations/KRSP", + "https://api.weather.gov/stations/KNUI", + "https://api.weather.gov/stations/KMRB", + "https://api.weather.gov/stations/KOKV", + "https://api.weather.gov/stations/KAPG", + "https://api.weather.gov/stations/KFRR", + "https://api.weather.gov/stations/K0W3", + "https://api.weather.gov/stations/KHGR", + "https://api.weather.gov/stations/KOMH", + "https://api.weather.gov/stations/K7W4", + "https://api.weather.gov/stations/KTHV", + "https://api.weather.gov/stations/KLKU", + "https://api.weather.gov/stations/KGVE", + "https://api.weather.gov/stations/KOFP", + "https://api.weather.gov/stations/K8W2", + "https://api.weather.gov/stations/KCHO", + "https://api.weather.gov/stations/KRIC", + "https://api.weather.gov/stations/KILG", + "https://api.weather.gov/stations/KLNS", + "https://api.weather.gov/stations/KCBE", + "https://api.weather.gov/stations/KSHD", + "https://api.weather.gov/stations/KVBW", + "https://api.weather.gov/stations/KMFV", + "https://api.weather.gov/stations/KW13", + "https://api.weather.gov/stations/KFVX", + "https://api.weather.gov/stations/K2G4", + "https://api.weather.gov/stations/K2G9", + "https://api.weather.gov/stations/KEKN", + "https://api.weather.gov/stations/KLYH", + "https://api.weather.gov/stations/KMGW", + "https://api.weather.gov/stations/KHSP", + "https://api.weather.gov/stations/KROA" + ], + "pagination": { + "next": "https://api.weather.gov/stations?id%5B0%5D=K0W3&id%5B1%5D=K2G4&id%5B2%5D=K2G9&id%5B3%5D=K2W6&id%5B4%5D=K7W4&id%5B5%5D=K8W2&id%5B6%5D=KADW&id%5B7%5D=KAPG&id%5B8%5D=KBWI&id%5B9%5D=KCBE&id%5B10%5D=KCGS&id%5B11%5D=KCHO&id%5B12%5D=KCJR&id%5B13%5D=KDAA&id%5B14%5D=KDCA&id%5B15%5D=KDMH&id%5B16%5D=KDMW&id%5B17%5D=KEKN&id%5B18%5D=KESN&id%5B19%5D=KEZF&id%5B20%5D=KFDK&id%5B21%5D=KFME&id%5B22%5D=KFRR&id%5B23%5D=KFVX&id%5B24%5D=KGAI&id%5B25%5D=KGVE&id%5B26%5D=KHEF&id%5B27%5D=KHGR&id%5B28%5D=KHSP&id%5B29%5D=KHWY&id%5B30%5D=KIAD&id%5B31%5D=KILG&id%5B32%5D=KJYO&id%5B33%5D=KLKU&id%5B34%5D=KLNS&id%5B35%5D=KLYH&id%5B36%5D=KMFV&id%5B37%5D=KMGW&id%5B38%5D=KMRB&id%5B39%5D=KMTN&id%5B40%5D=KNAK&id%5B41%5D=KNHK&id%5B42%5D=KNUI&id%5B43%5D=KNYG&id%5B44%5D=KOFP&id%5B45%5D=KOKV&id%5B46%5D=KOMH&id%5B47%5D=KRIC&id%5B48%5D=KRMN&id%5B49%5D=KROA&id%5B50%5D=KRSP&id%5B51%5D=KSHD&id%5B52%5D=KTHV&id%5B53%5D=KVBW&id%5B54%5D=KW13&id%5B55%5D=KW29&cursor=eyJzIjo1MDB9" + } +} diff --git a/tests/mocks/weather_yr.json b/tests/mocks/weather_yr.json new file mode 100644 index 0000000000..3d7dc66d80 --- /dev/null +++ b/tests/mocks/weather_yr.json @@ -0,0 +1,707 @@ +{ + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [10.7522, 59.9139, 5] }, + "properties": { + "meta": { + "updated_at": "2026-02-06T20:27:06Z", + "units": { "air_pressure_at_sea_level": "hPa", "air_temperature": "celsius", "cloud_area_fraction": "%", "precipitation_amount": "mm", "relative_humidity": "%", "wind_from_direction": "degrees", "wind_speed": "m/s" } + }, + "timeseries": [ + { + "time": "2026-02-06T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.6, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 66.5, "wind_from_direction": 37.0, "wind_speed": 6.0 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.5 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 3.5 } } + } + }, + { + "time": "2026-02-06T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.8, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 70.5, "wind_from_direction": 39.0, "wind_speed": 6.4 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.7 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 3.3 } } + } + }, + { + "time": "2026-02-06T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.1, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 73.3, "wind_from_direction": 41.0, "wind_speed": 6.6 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.8 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 2.6 } } + } + }, + { + "time": "2026-02-07T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.4, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 74.6, "wind_from_direction": 40.0, "wind_speed": 6.9 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.6 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 1.9 } } + } + }, + { + "time": "2026-02-07T01:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.5, "air_temperature": -5.7, "cloud_area_fraction": 100.0, "relative_humidity": 75.5, "wind_from_direction": 41.0, "wind_speed": 6.9 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.5 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 1.4 } } + } + }, + { + "time": "2026-02-07T02:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.5, "cloud_area_fraction": 100.0, "relative_humidity": 76.2, "wind_from_direction": 38.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.3 } }, + "next_6_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.9 } } + } + }, + { + "time": "2026-02-07T03:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.3, "cloud_area_fraction": 100.0, "relative_humidity": 76.6, "wind_from_direction": 37.0, "wind_speed": 5.2 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.2 } }, + "next_6_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.6 } } + } + }, + { + "time": "2026-02-07T04:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.2, "cloud_area_fraction": 100.0, "relative_humidity": 76.1, "wind_from_direction": 36.0, "wind_speed": 4.8 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.2 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T05:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.9, "air_temperature": -5.1, "cloud_area_fraction": 100.0, "relative_humidity": 75.6, "wind_from_direction": 35.0, "wind_speed": 4.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1016.5, "air_temperature": -5.0, "cloud_area_fraction": 100.0, "relative_humidity": 74.7, "wind_from_direction": 33.0, "wind_speed": 4.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T07:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.2, "air_temperature": -4.9, "cloud_area_fraction": 100.0, "relative_humidity": 73.7, "wind_from_direction": 35.0, "wind_speed": 4.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T08:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.9, "air_temperature": -4.7, "cloud_area_fraction": 99.8, "relative_humidity": 71.7, "wind_from_direction": 38.0, "wind_speed": 4.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T09:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1018.5, "air_temperature": -4.5, "cloud_area_fraction": 99.8, "relative_humidity": 70.2, "wind_from_direction": 43.0, "wind_speed": 5.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T10:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.0, "air_temperature": -4.1, "cloud_area_fraction": 100.0, "relative_humidity": 69.5, "wind_from_direction": 45.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T11:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.2, "air_temperature": -3.7, "cloud_area_fraction": 99.9, "relative_humidity": 68.7, "wind_from_direction": 45.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.2, "air_temperature": -3.1, "cloud_area_fraction": 93.4, "relative_humidity": 63.4, "wind_from_direction": 43.0, "wind_speed": 5.8 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T13:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.3, "air_temperature": -2.8, "cloud_area_fraction": 83.1, "relative_humidity": 59.5, "wind_from_direction": 46.0, "wind_speed": 6.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T14:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.5, "air_temperature": -2.7, "cloud_area_fraction": 79.7, "relative_humidity": 57.7, "wind_from_direction": 43.0, "wind_speed": 5.9 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T15:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.8, "air_temperature": -2.9, "cloud_area_fraction": 70.8, "relative_humidity": 56.6, "wind_from_direction": 40.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T16:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1020.3, "air_temperature": -3.6, "cloud_area_fraction": 55.6, "relative_humidity": 55.7, "wind_from_direction": 42.0, "wind_speed": 5.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T17:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1020.8, "air_temperature": -4.3, "cloud_area_fraction": 43.1, "relative_humidity": 54.0, "wind_from_direction": 43.0, "wind_speed": 5.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1021.5, "air_temperature": -4.8, "cloud_area_fraction": 27.4, "relative_humidity": 52.3, "wind_from_direction": 42.0, "wind_speed": 5.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T19:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1022.1, "air_temperature": -5.2, "cloud_area_fraction": 19.3, "relative_humidity": 53.2, "wind_from_direction": 43.0, "wind_speed": 5.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T20:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1022.7, "air_temperature": -5.5, "cloud_area_fraction": 10.2, "relative_humidity": 55.0, "wind_from_direction": 43.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.5, "air_temperature": -5.6, "cloud_area_fraction": 6.8, "relative_humidity": 61.3, "wind_from_direction": 43.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.2, "air_temperature": -5.9, "cloud_area_fraction": 38.5, "relative_humidity": 71.4, "wind_from_direction": 38.0, "wind_speed": 4.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.7, "air_temperature": -6.2, "cloud_area_fraction": 75.2, "relative_humidity": 77.8, "wind_from_direction": 36.0, "wind_speed": 4.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.2, "air_temperature": -6.4, "cloud_area_fraction": 79.6, "relative_humidity": 79.8, "wind_from_direction": 36.0, "wind_speed": 3.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T01:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -6.5, "cloud_area_fraction": 77.6, "relative_humidity": 80.0, "wind_from_direction": 34.0, "wind_speed": 3.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T02:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -6.5, "cloud_area_fraction": 71.4, "relative_humidity": 79.7, "wind_from_direction": 32.0, "wind_speed": 3.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T03:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.3, "air_temperature": -6.7, "cloud_area_fraction": 63.1, "relative_humidity": 79.9, "wind_from_direction": 32.0, "wind_speed": 3.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T04:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.3, "air_temperature": -7.1, "cloud_area_fraction": 62.1, "relative_humidity": 80.4, "wind_from_direction": 33.0, "wind_speed": 3.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T05:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.2, "air_temperature": -7.5, "cloud_area_fraction": 65.0, "relative_humidity": 82.2, "wind_from_direction": 45.0, "wind_speed": 2.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -7.7, "cloud_area_fraction": 77.7, "relative_humidity": 82.7, "wind_from_direction": 48.0, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T07:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.8, "air_temperature": -7.8, "cloud_area_fraction": 84.5, "relative_humidity": 82.2, "wind_from_direction": 48.0, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T08:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.2, "air_temperature": -7.6, "cloud_area_fraction": 82.8, "relative_humidity": 80.9, "wind_from_direction": 48.0, "wind_speed": 3.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T09:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.4, "air_temperature": -6.9, "cloud_area_fraction": 77.9, "relative_humidity": 78.9, "wind_from_direction": 46.0, "wind_speed": 3.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T10:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.3, "air_temperature": -6.2, "cloud_area_fraction": 82.3, "relative_humidity": 77.0, "wind_from_direction": 43.0, "wind_speed": 3.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T11:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.3, "air_temperature": -5.5, "cloud_area_fraction": 93.0, "relative_humidity": 76.6, "wind_from_direction": 49.0, "wind_speed": 3.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.1, "air_temperature": -5.1, "cloud_area_fraction": 98.9, "relative_humidity": 76.2, "wind_from_direction": 47.0, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T13:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.7, "air_temperature": -4.8, "cloud_area_fraction": 99.4, "relative_humidity": 76.2, "wind_from_direction": 50.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T14:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.4, "air_temperature": -4.8, "cloud_area_fraction": 95.5, "relative_humidity": 76.3, "wind_from_direction": 56.0, "wind_speed": 2.5 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T15:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.1, "air_temperature": -5.4, "cloud_area_fraction": 84.9, "relative_humidity": 77.2, "wind_from_direction": 56.0, "wind_speed": 2.6 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T16:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.8, "air_temperature": -6.1, "cloud_area_fraction": 57.9, "relative_humidity": 78.9, "wind_from_direction": 48.0, "wind_speed": 2.7 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T17:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.5, "air_temperature": -6.5, "cloud_area_fraction": 50.7, "relative_humidity": 81.3, "wind_from_direction": 38.0, "wind_speed": 2.5 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.3, "air_temperature": -6.9, "cloud_area_fraction": 72.7, "relative_humidity": 82.2, "wind_from_direction": 38.0, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T19:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.3, "air_temperature": -6.9, "cloud_area_fraction": 89.8, "relative_humidity": 81.9, "wind_from_direction": 44.0, "wind_speed": 1.9 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T20:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.2, "air_temperature": -7.0, "cloud_area_fraction": 96.6, "relative_humidity": 81.3, "wind_from_direction": 39.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.1, "air_temperature": -6.7, "cloud_area_fraction": 97.2, "relative_humidity": 79.9, "wind_from_direction": 40.0, "wind_speed": 2.8 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.8, "air_temperature": -6.7, "cloud_area_fraction": 97.6, "relative_humidity": 80.3, "wind_from_direction": 50.0, "wind_speed": 2.6 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.4, "air_temperature": -6.7, "cloud_area_fraction": 93.5, "relative_humidity": 80.7, "wind_from_direction": 53.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.1, "air_temperature": -7.1, "cloud_area_fraction": 80.0, "relative_humidity": 81.2, "wind_from_direction": 60.0, "wind_speed": 2.3 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.1, "air_temperature": -4.4, "cloud_area_fraction": 99.2, "relative_humidity": 85.9, "wind_from_direction": 339.8, "wind_speed": 1.1 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.8, "air_temperature": -4.3, "cloud_area_fraction": 100.0, "relative_humidity": 72.3, "wind_from_direction": 285.3, "wind_speed": 0.7 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.7, "air_temperature": -6.8, "cloud_area_fraction": 95.7, "relative_humidity": 82.1, "wind_from_direction": 346.8, "wind_speed": 0.6 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.9, "air_temperature": -8.8, "cloud_area_fraction": 97.7, "relative_humidity": 83.2, "wind_from_direction": 15.8, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1009.9, "air_temperature": -5.8, "cloud_area_fraction": 93.7, "relative_humidity": 82.2, "wind_from_direction": 22.4, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.5, "air_temperature": -3.5, "cloud_area_fraction": 100.0, "relative_humidity": 71.4, "wind_from_direction": 202.3, "wind_speed": 0.9 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.3, "air_temperature": -3.0, "cloud_area_fraction": 100.0, "relative_humidity": 81.9, "wind_from_direction": 22.3, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1002.5, "air_temperature": -2.3, "cloud_area_fraction": 100.0, "relative_humidity": 85.0, "wind_from_direction": 28.5, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1000.9, "air_temperature": -3.2, "cloud_area_fraction": 100.0, "relative_humidity": 85.5, "wind_from_direction": 28.1, "wind_speed": 1.6 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 999.8, "air_temperature": -2.0, "cloud_area_fraction": 100.0, "relative_humidity": 74.9, "wind_from_direction": 56.3, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.8, "air_temperature": -2.4, "cloud_area_fraction": 82.0, "relative_humidity": 77.8, "wind_from_direction": 29.5, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.3, "air_temperature": -2.9, "cloud_area_fraction": 100.0, "relative_humidity": 83.4, "wind_from_direction": 33.1, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.4, "air_temperature": -3.9, "cloud_area_fraction": 100.0, "relative_humidity": 83.0, "wind_from_direction": 24.1, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.9, "air_temperature": -3.3, "cloud_area_fraction": 99.6, "relative_humidity": 73.0, "wind_from_direction": 54.4, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 999.9, "air_temperature": -4.3, "cloud_area_fraction": 98.0, "relative_humidity": 81.3, "wind_from_direction": 24.0, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1001.9, "air_temperature": -4.6, "cloud_area_fraction": 39.8, "relative_humidity": 80.6, "wind_from_direction": 23.4, "wind_speed": 2.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.1, "air_temperature": -7.4, "cloud_area_fraction": 36.3, "relative_humidity": 81.8, "wind_from_direction": 21.9, "wind_speed": 1.9 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1005.7, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 73.2, "wind_from_direction": 33.1, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.7, "air_temperature": -5.0, "cloud_area_fraction": 0.0, "relative_humidity": 76.6, "wind_from_direction": 20.2, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.8, "air_temperature": -7.8, "cloud_area_fraction": 6.2, "relative_humidity": 78.8, "wind_from_direction": 23.1, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.4, "air_temperature": -11.8, "cloud_area_fraction": 21.9, "relative_humidity": 79.9, "wind_from_direction": 21.8, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.5, "air_temperature": -6.3, "cloud_area_fraction": 100.0, "relative_humidity": 70.5, "wind_from_direction": 25.3, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1008.0, "air_temperature": -5.5, "cloud_area_fraction": 100.0, "relative_humidity": 76.6, "wind_from_direction": 22.4, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1009.5, "air_temperature": -6.4, "cloud_area_fraction": 25.4, "relative_humidity": 76.8, "wind_from_direction": 18.6, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "fair_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.1, "air_temperature": -11.2, "cloud_area_fraction": 16.8, "relative_humidity": 79.5, "wind_from_direction": 17.5, "wind_speed": 1.6 } }, + "next_12_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1013.1, "air_temperature": -5.3, "cloud_area_fraction": 2.7, "relative_humidity": 59.4, "wind_from_direction": 197.5, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.2, "air_temperature": -7.4, "cloud_area_fraction": 2.3, "relative_humidity": 74.9, "wind_from_direction": 22.8, "wind_speed": 1.4 } }, + "next_12_hours": { "summary": { "symbol_code": "fair_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.9, "air_temperature": -9.3, "cloud_area_fraction": 2.3, "relative_humidity": 78.8, "wind_from_direction": 22.1, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.5, "air_temperature": -8.6, "cloud_area_fraction": 100.0, "relative_humidity": 82.1, "wind_from_direction": 17.7, "wind_speed": 1.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.1, "air_temperature": -3.0, "cloud_area_fraction": 3.9, "relative_humidity": 62.3, "wind_from_direction": 30.4, "wind_speed": 1.4 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T18:00:00Z", + "data": { "instant": { "details": { "air_pressure_at_sea_level": 1017.1, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 82.0, "wind_from_direction": 26.6, "wind_speed": 1.9 } } } + } + ] + } +} diff --git a/tests/unit/classes/deprecated_spec.js b/tests/unit/classes/deprecated_spec.js index ef78db911d..b1e6aa631d 100644 --- a/tests/unit/classes/deprecated_spec.js +++ b/tests/unit/classes/deprecated_spec.js @@ -5,11 +5,11 @@ describe("Deprecated", () => { expect(typeof deprecated).toBe("object"); }); - it("should contain configs array with deprecated options as strings", () => { - expect(Array.isArray(["deprecated.configs"])).toBe(true); + it("should contain clock array with deprecated options as strings", () => { + expect(Array.isArray(["deprecated.clock"])).toBe(true); for (let option of deprecated.configs) { expect(typeof option).toBe("string"); } - expect(deprecated.configs).toEqual(expect.arrayContaining(["kioskmode"])); + expect(deprecated.clock).toEqual(expect.arrayContaining(["secondsColor"])); }); }); diff --git a/tests/unit/classes/systeminformation_spec.js b/tests/unit/classes/systeminformation_spec.js new file mode 100644 index 0000000000..d9528c5410 --- /dev/null +++ b/tests/unit/classes/systeminformation_spec.js @@ -0,0 +1,7 @@ +const SystemInformation = require("../../../js/systeminformation"); + +describe("SystemInformation", () => { + it("should output system information", async () => { + await expect(SystemInformation()).resolves.toContain("platform: linux"); + }); +}); diff --git a/tests/unit/classes/translator_spec.js b/tests/unit/classes/translator_spec.js index 383f823bf8..2f4de65393 100644 --- a/tests/unit/classes/translator_spec.js +++ b/tests/unit/classes/translator_spec.js @@ -93,7 +93,7 @@ describe("Translator", () => { Translator.coreTranslationsFallback = coreTranslationsFallback; }; - it("should return custom module translation", async () => { + it("should return custom module translation", () => { const { Translator } = createTranslationTestEnvironment(); setTranslations(Translator); @@ -104,7 +104,7 @@ describe("Translator", () => { expect(translation).toBe("Hallo fewieden"); }); - it("should return core translation", async () => { + it("should return core translation", () => { const { Translator } = createTranslationTestEnvironment(); setTranslations(Translator); let translation = Translator.translate({ name: "MMM-Module" }, "FOO"); @@ -113,28 +113,28 @@ describe("Translator", () => { expect(translation).toBe("Bar Lorem Ipsum"); }); - it("should return custom module translation fallback", async () => { + it("should return custom module translation fallback", () => { const { Translator } = createTranslationTestEnvironment(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "A key"); expect(translation).toBe("A translation"); }); - it("should return core translation fallback", async () => { + it("should return core translation fallback", () => { const { Translator } = createTranslationTestEnvironment(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "Fallback"); expect(translation).toBe("core fallback"); }); - it("should return translation with placeholder for missing variables", async () => { + it("should return translation with placeholder for missing variables", () => { const { Translator } = createTranslationTestEnvironment(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}"); expect(translation).toBe("Hallo {username}"); }); - it("should return key if no translation was found", async () => { + it("should return key if no translation was found", () => { const { Translator } = createTranslationTestEnvironment(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "MISSING"); diff --git a/tests/unit/classes/utils_spec.js b/tests/unit/classes/utils_spec.js deleted file mode 100644 index 772bdf61df..0000000000 --- a/tests/unit/classes/utils_spec.js +++ /dev/null @@ -1,7 +0,0 @@ -const Utils = require("../../../js/utils"); - -describe("Utils", () => { - it("should output system information", async () => { - await expect(Utils.logSystemInformation()).resolves.toContain("platform: linux"); - }); -}); diff --git a/tests/unit/functions/http_fetcher_spec.js b/tests/unit/functions/http_fetcher_spec.js new file mode 100644 index 0000000000..046ddb9ca0 --- /dev/null +++ b/tests/unit/functions/http_fetcher_spec.js @@ -0,0 +1,442 @@ +const { http, HttpResponse } = require("msw"); +const { setupServer } = require("msw/node"); +const HTTPFetcher = require("#http_fetcher"); + +const TEST_URL = "http://test.example.com/data"; +let server; +let fetcher; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "error" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); + if (fetcher) { + fetcher.clearTimer(); + fetcher = null; + } +}); + +describe("HTTPFetcher", () => { + + describe("Basic fetch operations", () => { + it("should emit response event on successful fetch", async () => { + const responseData = "test data"; + server.use( + http.get(TEST_URL, () => { + return HttpResponse.text(responseData); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", (response) => { + resolve(response); + }); + }); + + fetcher.startPeriodicFetch(); + const response = await responsePromise; + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toBe(responseData); + }); + + it("should emit error event on network failure", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.error(); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo).toHaveProperty("errorType", "NETWORK_ERROR"); + expect(errorInfo).toHaveProperty("translationKey", "MODULE_ERROR_NO_CONNECTION"); + expect(errorInfo).toHaveProperty("url", TEST_URL); + }); + + it("should emit error event on timeout", async () => { + server.use( + http.get(TEST_URL, async () => { + // Simulate a slow server that never responds + await new Promise((resolve) => setTimeout(resolve, 60000)); + return HttpResponse.text("too late"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000, timeout: 100 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.errorType).toBe("NETWORK_ERROR"); + expect(errorInfo.message).toContain("timeout"); + expect(errorInfo.message).toContain("100ms"); + }); + }); + + describe("HTTPFetcher - HTTP status code handling", () => { + describe("401/403 errors (Auth failures)", () => { + it("should emit error with AUTH_FAILURE for 401", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 401 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(401); + expect(errorInfo.errorType).toBe("AUTH_FAILURE"); + expect(errorInfo.translationKey).toBe("MODULE_ERROR_UNAUTHORIZED"); + }); + + it("should emit error with AUTH_FAILURE for 403", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 403 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(403); + expect(errorInfo.errorType).toBe("AUTH_FAILURE"); + }); + }); + + describe("429 errors (Rate limiting)", () => { + it("should emit error with RATE_LIMITED for 429", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { + status: 429, + headers: { "Retry-After": "120" } + }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(429); + expect(errorInfo.errorType).toBe("RATE_LIMITED"); + expect(errorInfo.retryAfter).toBeGreaterThan(0); + }); + + it("should parse Retry-After header in seconds", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { + status: 429, + headers: { "Retry-After": "300" } + }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + // 300 seconds = 300000 ms + expect(errorInfo.retryAfter).toBe(300000); + }); + }); + + describe("5xx errors (Server errors)", () => { + it("should emit error with SERVER_ERROR for 500", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 500 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(500); + expect(errorInfo.errorType).toBe("SERVER_ERROR"); + }); + + it("should emit error with SERVER_ERROR for 503", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 503 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(503); + expect(errorInfo.errorType).toBe("SERVER_ERROR"); + }); + }); + + describe("4xx errors (Client errors)", () => { + it("should emit error with CLIENT_ERROR for 404", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 404 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(404); + expect(errorInfo.errorType).toBe("CLIENT_ERROR"); + }); + }); + }); +}); + +describe("HTTPFetcher - Authentication", () => { + it("should include Basic auth header when configured", async () => { + let receivedHeaders = null; + + server.use( + http.get(TEST_URL, ({ request }) => { + receivedHeaders = Object.fromEntries(request.headers); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + auth: { + method: "basic", + user: "testuser", + pass: "testpass" + } + }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + const expectedAuth = `Basic ${Buffer.from("testuser:testpass").toString("base64")}`; + expect(receivedHeaders.authorization).toBe(expectedAuth); + }); + + it("should include Bearer auth header when configured", async () => { + let receivedHeaders = null; + + server.use( + http.get(TEST_URL, ({ request }) => { + receivedHeaders = Object.fromEntries(request.headers); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + auth: { + method: "bearer", + pass: "my-token-123" + } + }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + expect(receivedHeaders.authorization).toBe("Bearer my-token-123"); + }); +}); + +describe("Custom headers", () => { + it("should include custom headers in request", async () => { + let receivedHeaders = null; + + server.use( + http.get(TEST_URL, ({ request }) => { + receivedHeaders = Object.fromEntries(request.headers); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + headers: { + "X-Custom-Header": "custom-value", + Accept: "application/json" + } + }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + expect(receivedHeaders["x-custom-header"]).toBe("custom-value"); + expect(receivedHeaders.accept).toBe("application/json"); + }); +}); + +describe("Timer management", () => { + it("should not set timer in test mode", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + // Timer should NOT be set in test mode (mmTestMode=true) + expect(fetcher.reloadTimer).toBeNull(); + }); + + it("should clear timer when clearTimer is called", () => { + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 }); + + // Manually set a timer to test clearing + fetcher.reloadTimer = setTimeout(() => {}, 10000); + expect(fetcher.reloadTimer).not.toBeNull(); + + fetcher.clearTimer(); + + expect(fetcher.reloadTimer).toBeNull(); + }); +}); + +describe("fetch() method", () => { + it("should emit response event when called", async () => { + const responseData = "direct fetch data"; + server.use( + http.get(TEST_URL, () => { + return HttpResponse.text(responseData); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + await fetcher.fetch(); + const response = await responsePromise; + + expect(response.ok).toBe(true); + const text = await response.text(); + expect(text).toBe(responseData); + }); + + it("should emit error event on network error", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.error(); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", resolve); + }); + + await fetcher.fetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.errorType).toBe("NETWORK_ERROR"); + }); +}); diff --git a/tests/unit/functions/server_functions_spec.js b/tests/unit/functions/server_functions_spec.js index 5383500ae6..8fd0a01450 100644 --- a/tests/unit/functions/server_functions_spec.js +++ b/tests/unit/functions/server_functions_spec.js @@ -1,22 +1,39 @@ -const { cors, getUserAgent } = require("#server_functions"); +const { cors, getUserAgent, replaceSecretPlaceholder } = require("#server_functions"); describe("server_functions tests", () => { + describe("The replaceSecretPlaceholder method", () => { + it("Calls string without secret placeholder", () => { + const teststring = "test string without secret placeholder"; + const result = replaceSecretPlaceholder(teststring); + expect(result).toBe(teststring); + }); + + it("Calls string with 2 secret placeholders", () => { + const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**"; + process.env.SECRET_ONE = "secret1"; + process.env.SECRET_TWO = "secret2"; + const resultstring = `test string with secret1=${process.env.SECRET_ONE} and secret2=${process.env.SECRET_TWO}`; + const result = replaceSecretPlaceholder(teststring); + expect(result).toBe(resultstring); + }); + }); + describe("The cors method", () => { let fetchResponse; let fetchResponseHeadersGet; - let fetchResponseHeadersText; + let fetchResponseArrayBuffer; let corsResponse; let request; let fetchMock; beforeEach(() => { fetchResponseHeadersGet = vi.fn(() => {}); - fetchResponseHeadersText = vi.fn(() => {}); + fetchResponseArrayBuffer = vi.fn(() => {}); fetchResponse = { headers: { get: fetchResponseHeadersGet }, - text: fetchResponseHeadersText, + arrayBuffer: fetchResponseArrayBuffer, ok: true }; @@ -78,7 +95,9 @@ describe("server_functions tests", () => { it("Sends correct data from response", async () => { const responseData = "some data"; - fetchResponseHeadersText.mockImplementation(() => responseData); + const encoder = new TextEncoder(); + const arrayBuffer = encoder.encode(responseData).buffer; + fetchResponseArrayBuffer.mockImplementation(() => arrayBuffer); let sentData; corsResponse.send = vi.fn((input) => { @@ -87,19 +106,19 @@ describe("server_functions tests", () => { await cors(request, corsResponse); - expect(fetchResponseHeadersText.mock.calls).toHaveLength(1); - expect(sentData).toBe(responseData); + expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1); + expect(sentData).toEqual(Buffer.from(arrayBuffer)); }); it("Sends error data from response", async () => { const error = new Error("error data"); - fetchResponseHeadersText.mockImplementation(() => { + fetchResponseArrayBuffer.mockImplementation(() => { throw error; }); await cors(request, corsResponse); - expect(fetchResponseHeadersText.mock.calls).toHaveLength(1); + expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1); expect(corsResponse.status).toHaveBeenCalledWith(500); expect(corsResponse.json).toHaveBeenCalledWith({ error: error.message }); }); @@ -144,7 +163,8 @@ describe("server_functions tests", () => { expect(corsResponse.set.mock.calls[2][1]).toBe("value2"); }); - it("Gets User-Agent from configuration", async () => { + it("Gets User-Agent from configuration", () => { + const previousConfig = global.config; global.config = {}; let userAgent; @@ -158,6 +178,8 @@ describe("server_functions tests", () => { global.config.userAgent = () => "Mozilla/5.0 (Bar)"; userAgent = getUserAgent(); expect(userAgent).toBe("Mozilla/5.0 (Bar)"); + + global.config = previousConfig; }); }); }); diff --git a/tests/unit/functions/updatenotification_spec.js b/tests/unit/functions/updatenotification_spec.js index ebf7580b81..f6617e4bf7 100644 --- a/tests/unit/functions/updatenotification_spec.js +++ b/tests/unit/functions/updatenotification_spec.js @@ -19,7 +19,8 @@ async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execShellSpyRe vi.doMock("logger", () => loggerMockRef.current); - const gitHelperModule = await import("../../../modules/default/updatenotification/git_helper"); + const defaults = await import("../../../js/defaults"); + const gitHelperModule = await import(`../../../${defaults.defaultModulesDir}/updatenotification/git_helper`); const GitHelper = gitHelperModule.default || gitHelperModule; const instance = new GitHelper(); execShellSpyRef.current = vi.spyOn(instance, "execShell"); @@ -319,7 +320,7 @@ describe("Updatenotification", () => { describe("custom module", () => { const moduleName = "MMM-Fuel"; - beforeEach(async () => { + beforeEach(() => { gitRemoteOut = `origin\thttps://github.com/fewieden/${moduleName}.git (fetch)\norigin\thttps://github.com/fewieden/${moduleName}.git (push)\n`; gitRevParseOut = "9d8310163da94441073a93cead711ba43e8888d0"; gitStatusOut = "## master...origin/master"; diff --git a/tests/unit/global_vars/defaults_modules_spec.js b/tests/unit/global_vars/defaults_modules_spec.js index 465e2232d0..993dcb63ee 100644 --- a/tests/unit/global_vars/defaults_modules_spec.js +++ b/tests/unit/global_vars/defaults_modules_spec.js @@ -1,14 +1,15 @@ const fs = require("node:fs"); const path = require("node:path"); +const defaults = require("../../../js/defaults"); const root_path = path.join(__dirname, "../../.."); -describe("Default modules set in modules/default/defaultmodules.js", () => { - const expectedDefaultModules = require(`${root_path}/modules/default/defaultmodules`); +describe("Default modules set in defaultmodules/defaultmodules.js", () => { + const expectedDefaultModules = require(`${root_path}/${defaults.defaultModulesDir}/defaultmodules`); for (const defaultModule of expectedDefaultModules) { - it(`contains a folder for modules/default/${defaultModule}"`, () => { - expect(fs.existsSync(path.join(root_path, "modules/default", defaultModule))).toBe(true); + it(`contains a folder for defaultmodules/${defaultModule}"`, () => { + expect(fs.existsSync(path.join(root_path, "defaultmodules", defaultModule))).toBe(true); }); } }); diff --git a/tests/unit/helpers/global-setup.js b/tests/unit/helpers/global-setup.js deleted file mode 100644 index 132a02bbd6..0000000000 --- a/tests/unit/helpers/global-setup.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = async () => { - process.env.TZ = "UTC"; -}; diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js index 4475c65c26..6b4a8dc651 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js @@ -1,6 +1,8 @@ global.moment = require("moment-timezone"); -const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils"); +const defaults = require("../../../js/defaults"); + +const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`); describe("Calendar fetcher utils test", () => { const defaultConfig = { diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js index de072a6728..bb7c86cac7 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -2,7 +2,9 @@ global.moment = require("moment-timezone"); const ical = require("node-ical"); const moment = require("moment-timezone"); -const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils"); +const defaults = require("../../../../../js/defaults"); + +const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`); describe("Calendar fetcher utils test", () => { const defaultConfig = { @@ -37,12 +39,19 @@ describe("Calendar fetcher utils test", () => { const yesterday = moment().subtract(1, "days").startOf("day").toDate(); const today = moment().startOf("day").toDate(); const tomorrow = moment().add(1, "days").startOf("day").toDate(); + const dayAfterTomorrow = moment().add(2, "days").startOf("day").toDate(); + // Mark as DATE-only (full-day) events per ICS convention + yesterday.dateOnly = true; + today.dateOnly = true; + tomorrow.dateOnly = true; + dayAfterTomorrow.dateOnly = true; + // ICS convention: DTEND for a full-day event is the exclusive next day const filteredEvents = CalendarFetcherUtils.filterEvents( { - pastEvent: { type: "VEVENT", start: yesterday, end: yesterday, summary: "pastEvent" }, - ongoingEvent: { type: "VEVENT", start: today, end: today, summary: "ongoingEvent" }, - upcomingEvent: { type: "VEVENT", start: tomorrow, end: tomorrow, summary: "upcomingEvent" } + pastEvent: { type: "VEVENT", start: yesterday, end: today, summary: "pastEvent" }, + ongoingEvent: { type: "VEVENT", start: today, end: tomorrow, summary: "ongoingEvent" }, + upcomingEvent: { type: "VEVENT", start: tomorrow, end: dayAfterTomorrow, summary: "upcomingEvent" } }, defaultConfig ); @@ -52,6 +61,58 @@ describe("Calendar fetcher utils test", () => { expect(filteredEvents[1].title).toBe("upcomingEvent"); }); + it("should hide excluded event with 'until' when far away and show it when close", () => { + // An event ending in 10 days with until='3 days' should be hidden now + const farStart = moment().add(9, "days").toDate(); + const farEnd = moment().add(10, "days").toDate(); + // An event ending in 1 day with until='3 days' should be shown (within 3 days of end) + const closeStart = moment().add(1, "hours").toDate(); + const closeEnd = moment().add(1, "days").toDate(); + + const config = { + ...defaultConfig, + excludedEvents: [{ filterBy: "Payment", until: "3 days" }] + }; + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + farPayment: { type: "VEVENT", start: farStart, end: farEnd, summary: "Payment due" }, + closePayment: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Payment reminder" }, + normalEvent: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Normal event" } + }, + config + ); + + // farPayment should be hidden (now < endDate - 3 days) + // closePayment should show (now >= endDate - 3 days) + // normalEvent should show (not matched by filter) + const titles = filteredEvents.map((e) => e.title); + expect(titles).not.toContain("Payment due"); + expect(titles).toContain("Payment reminder"); + expect(titles).toContain("Normal event"); + }); + + it("should fully exclude event when excludedEvents has no 'until'", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + const config = { + ...defaultConfig, + excludedEvents: ["Hidden"] + }; + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + hidden: { type: "VEVENT", start, end, summary: "Hidden event" }, + visible: { type: "VEVENT", start, end, summary: "Visible event" } + }, + config + ); + + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].title).toBe("Visible event"); + }); + it("should return the correct times when recurring events pass through daylight saving time", () => { const data = ical.parseICS(`BEGIN:VEVENT DTSTART;TZID=Europe/Amsterdam:20250311T090000 @@ -92,24 +153,348 @@ DTSTART;TZID=Europe/Amsterdam:20250311T090000 DTEND;TZID=Europe/Amsterdam:20250311T091500 RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU DTSTAMP:20250531T091103Z -ORGANIZER;CN=test:mailto:test@test.com UID:67e65a1d-b889-4451-8cab-5518cecb9c66 -CREATED:20230111T114612Z -DESCRIPTION:Test -LAST-MODIFIED:20250528T071312Z -SEQUENCE:1 -STATUS:CONFIRMED SUMMARY:Test -TRANSP:OPAQUE END:VEVENT`); - const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days")); + const instances = CalendarFetcherUtils.expandRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days")); + + const januaryFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "01-01"); + const julyFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "07-01"); + + // The underlying timestamps must represent 09:00 Amsterdam time, regardless of local timezone + expect(januaryFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+01:00"); + expect(julyFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+02:00"); + }); + + it("should return correct day-of-week for full-day recurring events across DST transitions", () => { + // Test case for GitHub issue #3976: recurring full-day events showing on wrong day + // This happens when DST transitions change the UTC offset between occurrences + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:20251027 +DTEND;VALUE=DATE:20251028 +RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3 +DTSTAMP:20260103T123138Z +UID:dst-test@google.com +SUMMARY:Weekly Monday Event +END:VEVENT +END:VCALENDAR`); + + // Simulate calendar with timezone (e.g., from X-WR-TIMEZONE or user config) + // This is how MagicMirror handles full-day events from calendars with timezones + data["dst-test@google.com"].start.tz = "America/Chicago"; + + const pastMoment = moment("2025-10-01"); + const futureMoment = moment("2025-11-30"); + const instances = CalendarFetcherUtils.expandRecurringEvent(data["dst-test@google.com"], pastMoment, futureMoment); + const startMoments = instances.map((i) => i.startMoment); + + // All occurrences should be on Monday (day() === 1) at midnight + // Oct 27, 2025 - Before DST ends + // Nov 3, 2025 - After DST ends (this was showing as Sunday before the fix) + // Nov 10, 2025 - After DST ends + expect(startMoments).toHaveLength(3); + expect(startMoments[0].day()).toBe(1); // Monday + expect(startMoments[0].format("YYYY-MM-DD")).toBe("2025-10-27"); + expect(startMoments[0].hour()).toBe(0); // Midnight + expect(startMoments[1].day()).toBe(1); // Monday (not Sunday!) + expect(startMoments[1].format("YYYY-MM-DD")).toBe("2025-11-03"); + expect(startMoments[1].hour()).toBe(0); // Midnight + expect(startMoments[2].day()).toBe(1); // Monday + expect(startMoments[2].format("YYYY-MM-DD")).toBe("2025-11-10"); + expect(startMoments[2].hour()).toBe(0); // Midnight + }); + + it("should show Facebook birthday events in the current year, not in the birth year", () => { + // Facebook birthday calendars use DTSTART with the actual birth year (e.g. 1990), + // which previously caused rrule.js to return the wrong year occurrence. + // With rrule-temporal this works correctly without any special-casing. + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:19900215 +RRULE:FREQ=YEARLY +DTSTAMP:20260101T000000Z +UID:birthday_123456789@facebook.com +SUMMARY:Jane Doe's Birthday +END:VEVENT +END:VCALENDAR`); + + const thisYear = moment().year(); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, { + ...defaultConfig, + maximumNumberOfDays: 366 + }); + + const birthdayEvents = filteredEvents.filter((e) => e.title === "Jane Doe's Birthday"); + expect(birthdayEvents.length).toBeGreaterThanOrEqual(1); + + // The event must expand to a recent year — NOT to the birth year 1990. + // It should be the current or next year depending on whether Feb 15 has already passed. + const startYear = moment(birthdayEvents[0].startDate, "x").year(); + expect(startYear).toBeGreaterThanOrEqual(thisYear); + expect(startYear).toBeLessThanOrEqual(thisYear + 1); + }); + + it("should produce a correctly shaped event object with all required fields", () => { + const start = moment().add(1, "day").startOf("hour").toDate(); + const end = moment().add(1, "day").startOf("hour").add(1, "hour") + .toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + event1: { + type: "VEVENT", + start, + end, + summary: "Team Meeting", + description: "Agenda TBD", + location: "Room 42", + geo: { lat: 52.52, lon: 13.4 }, + class: "PUBLIC", + uid: "shaped-event@test" + } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(1); + const ev = filteredEvents[0]; + expect(ev.title).toBe("Team Meeting"); + expect(ev.startDate).toBe(moment(start).format("x")); + expect(ev.endDate).toBe(moment(end).format("x")); + expect(ev.fullDayEvent).toBe(false); + expect(ev.recurringEvent).toBe(false); + expect(ev.class).toBe("PUBLIC"); + expect(ev.firstYear).toBe(moment(start).year()); + expect(ev.location).toBe("Room 42"); + expect(ev.geo).toEqual({ lat: 52.52, lon: 13.4 }); + expect(ev.description).toBe("Agenda TBD"); + }); + + it("should return correct firstYear for a full-day event on January 1st", () => { + // node-ical creates DATE-only events with the local Date constructor: new Date(year, month, day). + // getFullYear() on a locally-constructed date always returns the correct calendar year + // regardless of the server's UTC offset — guard against regressions that switch to getUTCFullYear(). + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:19900101 +DTEND;VALUE=DATE:19900102 +RRULE:FREQ=YEARLY +UID:newyear-birthday@test +SUMMARY:New Year Baby +END:VEVENT +END:VCALENDAR`); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, { + ...defaultConfig, + maximumNumberOfDays: 366 + }); + + const birthday = filteredEvents.find((e) => e.title === "New Year Baby"); + expect(birthday).toBeDefined(); + expect(birthday.firstYear).toBe(1990); + }); + }); + + describe("expandRecurringEvent", () => { + it("should extend end to end-of-day when event has no DTEND", () => { + // node-ical sets end === start when DTEND is absent; our code extends to endOf("day") + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260222T100000Z +UID:no-end-test@test +SUMMARY:No End Event +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent(data["no-end-test@test"], moment("2026-02-20"), moment("2026-02-24")); + + expect(instances).toHaveLength(1); + expect(instances[0].endMoment.format("HH:mm:ss")).toBe("23:59:59"); + }); + + it("should apply RECURRENCE-ID overrides (moved single occurrence)", () => { + // A weekly event on Mondays at 10:00, but the second occurrence is moved to Tuesday 14:00 + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260302T100000 +DTEND;TZID=Europe/Berlin:20260302T110000 +RRULE:FREQ=WEEKLY;COUNT=3 +UID:recurrence-override@test +SUMMARY:Weekly Standup +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260310T140000 +DTEND;TZID=Europe/Berlin:20260310T150000 +RECURRENCE-ID;TZID=Europe/Berlin:20260309T100000 +UID:recurrence-override@test +SUMMARY:Moved Standup +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["recurrence-override@test"], + moment("2026-03-01"), + moment("2026-03-31") + ); + + expect(instances).toHaveLength(3); + + // First occurrence: Monday March 2, 10:00 (unchanged) + expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-02 10:00"); + expect(CalendarFetcherUtils.getTitleFromEvent(instances[0].event)).toBe("Weekly Standup"); + + // Second occurrence: moved to Tuesday March 10, 14:00 + expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-10 14:00"); + expect(CalendarFetcherUtils.getTitleFromEvent(instances[1].event)).toBe("Moved Standup"); + + // Third occurrence: Monday March 16, 10:00 (unchanged) + expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-16 10:00"); + }); + + it("should handle events with DURATION instead of DTEND", () => { + // RFC 5545 allows DURATION as alternative to DTEND + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260315T090000Z +DURATION:PT1H30M +UID:duration-test@test +SUMMARY:Duration Event +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["duration-test@test"], + moment("2026-03-14"), + moment("2026-03-16") + ); + + expect(instances).toHaveLength(1); + // End should be 90 minutes after start + const durationMinutes = instances[0].endMoment.diff(instances[0].startMoment, "minutes"); + expect(durationMinutes).toBe(90); + }); + + it("should handle recurring events with DURATION instead of DTEND", () => { + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260301T080000 +DURATION:PT45M +RRULE:FREQ=DAILY;COUNT=3 +UID:recurring-duration@test +SUMMARY:Daily Scrum +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["recurring-duration@test"], + moment("2026-02-28"), + moment("2026-03-05") + ); + + expect(instances).toHaveLength(3); + for (const inst of instances) { + const durationMinutes = inst.endMoment.diff(inst.startMoment, "minutes"); + expect(durationMinutes).toBe(45); + } + expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-01"); + expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-02"); + expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-03"); + }); + }); + + describe("filterEvents error handling", () => { + it("should skip a broken event but still return other valid events", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + throw new TypeError("invalid rrule"); + }); + + const result = CalendarFetcherUtils.filterEvents( + { + brokenEvent: { type: "VEVENT", start, end, summary: "Broken" }, + goodEvent: { type: "VEVENT", start, end, summary: "Good" } + }, + defaultConfig + ); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Good"); + }); - const januaryFirst = moments.filter((m) => m.format("MM-DD") === "01-01"); - const julyFirst = moments.filter((m) => m.format("MM-DD") === "07-01"); + it("should let expandRecurringEvent throw through directly", () => { + vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + throw new TypeError("invalid rrule"); + }); + + const event = { type: "VEVENT", start: new Date(), end: new Date(), summary: "Broken Event" }; + expect(() => CalendarFetcherUtils.expandRecurringEvent(event, moment(), moment().add(1, "days"))).toThrow("invalid rrule"); + }); + }); + + describe("unwrapParameterValue", () => { + it("should return the val of a ParameterValue object", () => { + expect(CalendarFetcherUtils.unwrapParameterValue({ val: "Text", params: { LANGUAGE: "de" } })).toBe("Text"); + }); + + it("should return a plain string unchanged", () => { + expect(CalendarFetcherUtils.unwrapParameterValue("plain")).toBe("plain"); + }); + + it("should return falsy values unchanged", () => { + expect(CalendarFetcherUtils.unwrapParameterValue(undefined)).toBeUndefined(); + expect(CalendarFetcherUtils.unwrapParameterValue(false)).toBe(false); + }); + }); + + describe("getTitleFromEvent", () => { + it("should return summary string directly", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ summary: "My Event" })).toBe("My Event"); + }); + + it("should unwrap ParameterValue summary", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ summary: { val: "My Event", params: {} } })).toBe("My Event"); + }); + + it("should fall back to description string", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ description: "Desc" })).toBe("Desc"); + }); + + it("should unwrap ParameterValue description as fallback title", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ description: { val: "Desc", params: { LANGUAGE: "de" } } })).toBe("Desc"); + }); + + it("should return 'Event' when neither summary nor description is present", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({})).toBe("Event"); + }); + }); + + describe("filterEvents with ParameterValue properties", () => { + it("should handle DESCRIPTION;LANGUAGE=de and LOCATION;LANGUAGE=de without [object Object]", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + event1: { + type: "VEVENT", + start, + end, + summary: "Test", + description: { val: "Beschreibung", params: { LANGUAGE: "de" } }, + location: { val: "Berlin", params: { LANGUAGE: "de" } } + } + }, + defaultConfig + ); - expect(januaryFirst[0].toISOString(true)).toContain("09:00:00.000+01:00"); - expect(julyFirst[0].toISOString(true)).toContain("09:00:00.000+02:00"); + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].description).toBe("Beschreibung"); + expect(filteredEvents[0].location).toBe("Berlin"); }); }); }); diff --git a/tests/unit/modules/default/calendar/calendar_utils_spec.js b/tests/unit/modules/default/calendar/calendar_utils_spec.js index ff37299fe9..77d358ddfa 100644 --- a/tests/unit/modules/default/calendar/calendar_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_utils_spec.js @@ -1,6 +1,8 @@ global.moment = require("moment"); -const CalendarUtils = require("../../../../../modules/default/calendar/calendarutils"); +const defaults = require("../../../../../js/defaults"); + +const CalendarUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarutils`); describe("Calendar utils tests", () => { describe("capFirst", () => { diff --git a/tests/unit/modules/default/compliments/compliments_spec.js b/tests/unit/modules/default/compliments/compliments_spec.js index 8d3aef3011..c1b76fcc09 100644 --- a/tests/unit/modules/default/compliments/compliments_spec.js +++ b/tests/unit/modules/default/compliments/compliments_spec.js @@ -16,12 +16,13 @@ describe("Compliments module", () => { global.Cron = vi.fn(); // Load the module - require("../../../../../modules/default/compliments/compliments"); + const defaults = require("../../../../../js/defaults"); + require(`../../../../../${defaults.defaultModulesDir}/compliments/compliments`); // Setup module instance complimentsModule.config = { ...complimentsModule.defaults }; complimentsModule.name = "compliments"; - complimentsModule.file = vi.fn((path) => `http://localhost:8080/modules/default/compliments/${path}`); + complimentsModule.file = vi.fn((path) => `http://localhost:8080/${defaults.defaultModulesDir}/compliments/${path}`); }); afterEach(() => { @@ -144,7 +145,7 @@ describe("Compliments module", () => { await complimentsModule.loadComplimentFile(); const calledUrl = fetch.mock.calls[0][0]; - expect(calledUrl).toBe("http://localhost:8080/modules/default/compliments/compliments.json"); + expect(calledUrl).toBe("http://localhost:8080/defaultmodules/compliments/compliments.json"); expect(calledUrl).not.toContain("dummy="); }); }); diff --git a/tests/unit/modules/default/utils_spec.js b/tests/unit/modules/default/utils_spec.js index 9677f3df20..e5d01632a8 100644 --- a/tests/unit/modules/default/utils_spec.js +++ b/tests/unit/modules/default/utils_spec.js @@ -1,113 +1,13 @@ global.moment = require("moment-timezone"); -const { performWebRequest, formatTime } = require("../../../../modules/default/utils"); +const defaults = require("../../../../js/defaults"); -describe("Default modules utils tests", () => { - describe("performWebRequest", () => { - const locationHost = "localhost:8080"; - const locationProtocol = "http"; - - let fetchResponse; - let fetchMock; - let urlToCall; - - beforeEach(() => { - fetchResponse = new Response(); - global.fetch = vi.fn(() => Promise.resolve(fetchResponse)); - fetchMock = global.fetch; - }); - - describe("When using cors proxy", () => { - Object.defineProperty(global, "location", { - value: { - host: locationHost, - protocol: locationProtocol - } - }); - - it("Calls correct URL once", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - - await performWebRequest(urlToCall, "json", true); - - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?url=${urlToCall}`); - }); - - it("Sends correct headers", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - - const headers = [ - { name: "header1", value: "value1" }, - { name: "header2", value: "value2" } - ]; - - await performWebRequest(urlToCall, "json", true, headers); - - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?sendheaders=header1:value1,header2:value2&url=${urlToCall}`); - }); - }); - - describe("When not using cors proxy", () => { - it("Calls correct URL once", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - - await performWebRequest(urlToCall); - - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][0]).toBe(urlToCall); - }); - - it("Sends correct headers", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - const headers = [ - { name: "header1", value: "value1" }, - { name: "header2", value: "value2" } - ]; - - await performWebRequest(urlToCall, "json", false, headers); - - const expectedHeaders = { headers: { header1: "value1", header2: "value2" } }; - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][1]).toStrictEqual(expectedHeaders); - }); - }); - - describe("When receiving json format", () => { - it("Returns undefined when no data is received", async () => { - urlToCall = "www.test.com"; - - const response = await performWebRequest(urlToCall); - - expect(response).toBeUndefined(); - }); - - it("Returns object when data is received", async () => { - urlToCall = "www.test.com"; - fetchResponse = new Response("{\"body\": \"some content\"}"); - - const response = await performWebRequest(urlToCall); - - expect(response.body).toBe("some content"); - }); - - it("Returns expected headers when data is received", async () => { - urlToCall = "www.test.com"; - fetchResponse = new Response("{\"body\": \"some content\"}", { headers: { header1: "value1", header2: "value2" } }); - - const response = await performWebRequest(urlToCall, "json", false, undefined, ["header1"]); - - expect(response.headers).toHaveLength(1); - expect(response.headers[0].name).toBe("header1"); - expect(response.headers[0].value).toBe("value1"); - }); - }); - }); +const { formatTime } = require(`../../../../${defaults.defaultModulesDir}/utils`); +describe("Default modules utils tests", () => { describe("formatTime", () => { const time = new Date(); - beforeEach(async () => { + beforeEach(() => { time.setHours(13, 13); }); diff --git a/tests/unit/modules/default/weather/provider_utils_spec.js b/tests/unit/modules/default/weather/provider_utils_spec.js new file mode 100644 index 0000000000..511a84340f --- /dev/null +++ b/tests/unit/modules/default/weather/provider_utils_spec.js @@ -0,0 +1,167 @@ +const defaults = require("../../../../../js/defaults"); + +const providerUtils = require(`../../../../../${defaults.defaultModulesDir}/weather/provider-utils`); + +describe("Weather provider utils tests", () => { + describe("convertWeatherType", () => { + it("should convert OpenWeatherMap day icons correctly", () => { + expect(providerUtils.convertWeatherType("01d")).toBe("day-sunny"); + expect(providerUtils.convertWeatherType("02d")).toBe("day-cloudy"); + expect(providerUtils.convertWeatherType("10d")).toBe("rain"); + expect(providerUtils.convertWeatherType("13d")).toBe("snow"); + }); + + it("should convert OpenWeatherMap night icons correctly", () => { + expect(providerUtils.convertWeatherType("01n")).toBe("night-clear"); + expect(providerUtils.convertWeatherType("02n")).toBe("night-cloudy"); + expect(providerUtils.convertWeatherType("10n")).toBe("night-rain"); + }); + + it("should return null for unknown weather types", () => { + expect(providerUtils.convertWeatherType("99x")).toBeNull(); + expect(providerUtils.convertWeatherType("")).toBeNull(); + }); + }); + + describe("applyTimezoneOffset", () => { + it("should apply positive offset correctly", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, 120); // +2 hours + // The function converts to UTC, then applies offset + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 + 120 * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + + it("should apply negative offset correctly", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, -300); // -5 hours + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 - 300 * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + + it("should handle zero offset", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, 0); + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + }); + + describe("limitDecimals", () => { + it("should truncate decimals correctly", () => { + expect(providerUtils.limitDecimals(12.3456789, 4)).toBe(12.3456); + expect(providerUtils.limitDecimals(12.3456789, 2)).toBe(12.34); + }); + + it("should handle values with fewer decimals than limit", () => { + expect(providerUtils.limitDecimals(12.34, 6)).toBe(12.34); + expect(providerUtils.limitDecimals(12, 4)).toBe(12); + }); + + it("should handle negative values", () => { + expect(providerUtils.limitDecimals(-12.3456789, 2)).toBe(-12.34); + }); + + it("should truncate not round", () => { + expect(providerUtils.limitDecimals(12.9999, 2)).toBe(12.99); + expect(providerUtils.limitDecimals(12.9999, 0)).toBe(12); + }); + }); + + describe("getSunTimes", () => { + it("should return sunrise and sunset times", () => { + const date = new Date("2026-06-21T12:00:00Z"); // Summer solstice + const lat = 52.52; // Berlin + const lon = 13.405; + + const result = providerUtils.getSunTimes(date, lat, lon); + + expect(result).toHaveProperty("sunrise"); + expect(result).toHaveProperty("sunset"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.sunrise.getTime()).toBeLessThan(result.sunset.getTime()); + }); + + it("should handle different locations", () => { + const date = new Date("2026-06-21T12:00:00Z"); + + // London + const london = providerUtils.getSunTimes(date, 51.5074, -0.1278); + // Tokyo + const tokyo = providerUtils.getSunTimes(date, 35.6762, 139.6503); + + expect(london.sunrise.getTime()).not.toBe(tokyo.sunrise.getTime()); + }); + }); + + describe("isDayTime", () => { + it("should return true when time is between sunrise and sunset", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const noon = new Date("2026-02-02T12:00:00Z"); + + expect(providerUtils.isDayTime(noon, sunrise, sunset)).toBe(true); + }); + + it("should return false when time is before sunrise", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const night = new Date("2026-02-02T03:00:00Z"); + + expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false); + }); + + it("should return false when time is after sunset", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const night = new Date("2026-02-02T20:00:00Z"); + + expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false); + }); + + it("should return true if sunrise/sunset are null", () => { + const noon = new Date("2026-02-02T12:00:00Z"); + expect(providerUtils.isDayTime(noon, null, null)).toBe(true); + }); + }); + + describe("formatTimezoneOffset", () => { + it("should format positive offsets correctly", () => { + expect(providerUtils.formatTimezoneOffset(60)).toBe("+01:00"); + expect(providerUtils.formatTimezoneOffset(120)).toBe("+02:00"); + expect(providerUtils.formatTimezoneOffset(330)).toBe("+05:30"); // India + }); + + it("should format negative offsets correctly", () => { + expect(providerUtils.formatTimezoneOffset(-300)).toBe("-05:00"); // EST + expect(providerUtils.formatTimezoneOffset(-480)).toBe("-08:00"); // PST + }); + + it("should format zero offset correctly", () => { + expect(providerUtils.formatTimezoneOffset(0)).toBe("+00:00"); + }); + + it("should pad single digits with zero", () => { + expect(providerUtils.formatTimezoneOffset(5)).toBe("+00:05"); + expect(providerUtils.formatTimezoneOffset(-5)).toBe("-00:05"); + }); + }); + + describe("getDateString", () => { + it("should format date as YYYY-MM-DD (local time)", () => { + const date = new Date(2026, 1, 2, 12, 34, 56); // Feb 2, 2026 (month is 0-indexed) + expect(providerUtils.getDateString(date)).toBe("2026-02-02"); + }); + + it("should handle single-digit months and days correctly", () => { + const date = new Date(2026, 0, 5, 12, 0, 0); // Jan 5, 2026 + expect(providerUtils.getDateString(date)).toBe("2026-01-05"); + }); + + it("should handle end of year", () => { + const date = new Date(2025, 11, 31, 23, 59, 59); // Dec 31, 2025 + expect(providerUtils.getDateString(date)).toBe("2025-12-31"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/envcanada_spec.js b/tests/unit/modules/default/weather/providers/envcanada_spec.js new file mode 100644 index 0000000000..63086710b5 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/envcanada_spec.js @@ -0,0 +1,309 @@ +/** + * Environment Canada Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Environment Canada is the Canadian weather service (XML-based). + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const indexHTML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada_index.html"), "utf-8"); +const cityPageXML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada.xml"), "utf-8"); + +// Match directory listing (index) - must end with / and nothing after +const ENVCANADA_INDEX_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/$/; +// Match actual XML files +const ENVCANADA_CITYPAGE_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/.*\.xml$/; + +let server; + +beforeAll(() => { + server = setupServer( + http.get(ENVCANADA_INDEX_PATTERN, () => { + return new HttpResponse(indexHTML, { + headers: { "Content-Type": "text/html" } + }); + }), + http.get(ENVCANADA_CITYPAGE_PATTERN, () => { + return new HttpResponse(cityPageXML, { + headers: { "Content-Type": "application/xml" } + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("EnvCanadaProvider", () => { + let EnvCanadaProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/envcanada"); + EnvCanadaProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + expect(provider.config.siteCode).toBe("s0000458"); + expect(provider.config.provCode).toBe("ON"); + expect(provider.config.type).toBe("current"); + }); + + it("should throw error if siteCode or provCode missing", () => { + const provider = new EnvCanadaProvider({ siteCode: "", provCode: "" }); + provider.setCallbacks(vi.fn(), vi.fn()); + expect(() => provider.initialize()).toThrow("siteCode and provCode are required"); + }); + }); + + describe("Two-Step Fetch Pattern", () => { + it("should first fetch index page then city page", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + expect(result).toBeDefined(); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-20.3); + expect(result.windSpeed).toBeCloseTo(5.28, 1); // 19 km/h -> m/s + expect(result.windFromDirection).toBe(346); // NNW + expect(result.humidity).toBe(67); + }); + + it("should use wind chill for feels like temperature when available", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // XML has windChill of -31 + expect(result.feelsLikeTemp).toBe(-31); + }); + + it("should parse sunrise/sunset from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon code to weather type", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Icon code 40 = "Blowing Snow" → "snow-wind" + expect(result.weatherType).toBe("snow-wind"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("precipitationProbability"); + expect(day).toHaveProperty("weatherType"); + }); + + it("should extract max precipitation probability from day/night", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has 40% for both day and night periods + expect(result[0].precipitationProbability).toBe(40); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(24); // Real data has 24 hourly forecasts + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("precipitationProbability"); + expect(hour).toHaveProperty("weatherType"); + }); + + it("should parse EC time format correctly", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First hourly forecast is for 202602071300 = 2026-02-07 13:00 UTC + const expectedDate = new Date(Date.UTC(2026, 1, 7, 13, 0, 0)); + expect(result[0].date.getTime()).toBe(expectedDate.getTime()); + }); + }); + + describe("Error Handling", () => { + it("should handle missing city page URL", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s9999999", // Invalid site code + provCode: "ON", + type: "current" + }); + + let errorCalled = false; + provider.setCallbacks(vi.fn(), () => { + errorCalled = true; + }); + + await provider.initialize(); + provider.start(); + + // Should not call error callback if URL not found (it's expected during hour transitions) + // Wait a bit to see if callback is called + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(errorCalled).toBe(false); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/openmeteo_spec.js b/tests/unit/modules/default/weather/providers/openmeteo_spec.js new file mode 100644 index 0000000000..0377a99013 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/openmeteo_spec.js @@ -0,0 +1,340 @@ +/** + * OpenMeteo Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Uses MSW to mock HTTP responses from the Open-Meteo API. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import openMeteoData from "../../../../../mocks/weather_openmeteo_current.json" with { type: "json" }; +import openMeteoCurrentWeatherData from "../../../../../mocks/weather_openmeteo_current_weather.json" with { type: "json" }; +// Real API returns current + forecast in one response +const currentData = openMeteoCurrentWeatherData; +const forecastData = openMeteoData; + +const GEOCODE_URL = "https://api.bigdatacloud.net/data/reverse-geocode-client*"; + +let server; + +beforeAll(() => { + // Mock global fetch for geocoding (used by provider's #fetchLocation) + server = setupServer( + http.get(GEOCODE_URL, () => { + return HttpResponse.json({ + city: "Munich", + locality: "Munich", + principalSubdivisionCode: "BY" + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("OpenMeteoProvider", () => { + let OpenMeteoProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/openmeteo"); + OpenMeteoProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const provider = new OpenMeteoProvider({}); + expect(provider.config.lat).toBe(0); + expect(provider.config.lon).toBe(0); + expect(provider.config.type).toBe("current"); + expect(provider.config.maxNumberOfDays).toBe(5); + expect(provider.config.apiBase).toBe("https://api.open-meteo.com/v1"); + }); + + it("should initialize without callbacks", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + await expect(provider.initialize()).resolves.not.toThrow(); + }); + + it("should resolve location name via geocoding", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + await provider.initialize(); + expect(provider.locationName).toBe("Munich, BY"); + }); + + it("should use forecast_days instead of static start_date/end_date", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + await provider.initialize(); + + const url = new URL(provider.fetcher.url); + const params = url.searchParams; + + expect(params.get("forecast_days")).toBe("1"); + expect(params.has("start_date")).toBe(false); + expect(params.has("end_date")).toBe(false); + }); + + it("should set forecast_days based on maxNumberOfDays for forecast type", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast", + maxNumberOfDays: 5 + }); + + await provider.initialize(); + + const url = new URL(provider.fetcher.url); + const params = url.searchParams; + + expect(params.get("forecast_days")).toBe("6"); // 5 days + 1 + expect(params.has("start_date")).toBe(false); + expect(params.has("end_date")).toBe(false); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data correctly", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(8.5); + expect(result.windSpeed).toBeCloseTo(4.7, 1); + expect(result.windFromDirection).toBe(9); + expect(result.humidity).toBe(83); + }); + + it("should include sunrise and sunset from daily data", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.minTemperature).toBe(4.7); + expect(result.maxTemperature).toBe(9.5); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data correctly", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(7); + const firstDay = result[0]; + expect(firstDay.minTemperature).toBe(-9.2); + expect(firstDay.maxTemperature).toBe(-0.2); + expect(firstDay.temperature).toBeCloseTo(-4.7, 0); // (-0.2+-9.2)/2 + + expect(firstDay.sunrise).toBeInstanceOf(Date); + expect(firstDay.sunset).toBeInstanceOf(Date); + }); + + it("should include precipitation data in forecast", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Mock data has no rain_sum field - provider returns null for missing data + expect(result[0].rain).toBeNull(); + // precipitation_sum has value 0.0 in mock data + expect(result[0].precipitationAmount).toBe(0.0); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid API response", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + + it("should call error callback on network failure", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.error(); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("url"); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const provider = new OpenMeteoProvider({}); + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + }); + + describe("Lifecycle", () => { + it("should have start/stop methods", () => { + const provider = new OpenMeteoProvider({}); + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should clear timer on stop", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.stop(); + + // Should not throw + expect(provider.fetcher).not.toBeNull(); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/openweathermap_spec.js b/tests/unit/modules/default/weather/providers/openweathermap_spec.js new file mode 100644 index 0000000000..7b27fd711f --- /dev/null +++ b/tests/unit/modules/default/weather/providers/openweathermap_spec.js @@ -0,0 +1,235 @@ +/** + * OpenWeatherMap Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import onecallData from "../../../../../mocks/weather_owm_onecall.json" with { type: "json" }; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("OpenWeatherMapProvider", () => { + let OpenWeatherMapProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/openweathermap"); + OpenWeatherMapProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key" + }); + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.apiKey).toBe("test-key"); + }); + + it("should have default values", () => { + const provider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(provider.config.apiVersion).toBe("3.0"); + expect(provider.config.weatherEndpoint).toBe("/onecall"); + expect(provider.config.apiBase).toBe("https://api.openweathermap.org/data/"); + }); + }); + + describe("API Key Validation", () => { + it("should call error callback without API key", async () => { + const provider = new OpenWeatherMapProvider({ apiKey: "" }); + const onError = vi.fn(); + provider.setCallbacks(vi.fn(), onError); + await provider.initialize(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "API key is required" }) + ); + }); + + it("should not create fetcher without API key", async () => { + const provider = new OpenWeatherMapProvider({ apiKey: "" }); + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + expect(provider.fetcher).toBeNull(); + }); + + it("should throw if setCallbacks not called before initialize", () => { + const provider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(() => provider.initialize()).toThrow("setCallbacks"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse onecall current weather data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.temperature).toBe(-0.27); + expect(result.windSpeed).toBe(3.09); + expect(result.windFromDirection).toBe(220); + expect(result.humidity).toBe(54); + expect(result.uvIndex).toBe(0); + expect(result.feelsLikeTemp).toBe(-3.9); + expect(result.weatherType).toBe("cloudy-windy"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should include precipitation data in current weather", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has no precipitation + expect(result.rain).toBeUndefined(); + expect(result.snow).toBeUndefined(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(8); + expect(result[0].minTemperature).toBe(-11.86); + expect(result[0].maxTemperature).toBe(-0.27); + expect(result[0].snow).toBe(0.69); + expect(result[0].precipitationProbability).toBe(100); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(48); + expect(result[0].temperature).toBe(-0.66); + expect(result[0].precipitationProbability).toBe(0); + expect(result[0].rain).toBeUndefined(); + }); + }); + + describe("Timezone Handling", () => { + it("should set location name from timezone", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + await dataPromise; + + expect(provider.locationName).toBe("America/New_York"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/pirateweather_spec.js b/tests/unit/modules/default/weather/providers/pirateweather_spec.js new file mode 100644 index 0000000000..e2e3a8e103 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/pirateweather_spec.js @@ -0,0 +1,366 @@ +/** + * Pirate Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Pirate Weather is a Dark Sky API compatible service. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import pirateweatherData from "../../../../../mocks/weather_pirateweather.json" with { type: "json" }; + +const PIRATEWEATHER_URL = "https://api.pirateweather.net/forecast/*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("PirateweatherProvider", () => { + let PirateweatherProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/pirateweather"); + PirateweatherProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new PirateweatherProvider({ + apiKey: "test-api-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + }); + + it("should error if API key is missing", async () => { + const provider = new PirateweatherProvider({ + lat: 40.71, + lon: -74.0 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-0.26); + expect(result.feelsLikeTemp).toBe(-4.77); + expect(result.windSpeed).toBe(2.32); + expect(result.windFromDirection).toBe(166); + expect(Math.round(result.humidity)).toBe(56); // 0.56 * 100 with rounding + }); + + it("should include sunrise/sunset from daily data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon to weather type", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // "cloudy" icon from real data + expect(result.weatherType).toBe("cloudy"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(8); + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + + it("should convert precipitation accumulation from cm to mm", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day has precipAccumulation: 0.0 cm + expect(result[0].precipitationAmount).toBe(0); + }); + + it("should categorize precipitation by type", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day has precipType: "snow" + expect(result[0].rain).toBe(0); + expect(result[0].snow).toBe(0); + + // Second day has precipType: "snow" with 0.0 accumulation + expect(result[1].rain).toBe(0); + expect(result[1].snow).toBe(0); + }); + + it("should convert precipitation probability to percentage", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // 0.33 -> 33% + expect(result[0].precipitationProbability).toBe(33); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(48); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("feelsLikeTemp"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("weatherType"); + }); + + it("should handle hourly precipitation", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First hour has 0.0 cm precipitation + expect(result[0].precipitationAmount).toBe(0); + expect(result[0].rain).toBe(0); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid JSON response", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error.message).toContain("No usable data"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/smhi_spec.js b/tests/unit/modules/default/weather/providers/smhi_spec.js new file mode 100644 index 0000000000..b7e1211785 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/smhi_spec.js @@ -0,0 +1,208 @@ +/** + * SMHI Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * SMHI provides data only for Sweden, uses metric system. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import smhiData from "../../../../../mocks/weather_smhi.json" with { type: "json" }; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("SMHIProvider", () => { + let SMHIProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/smhi"); + SMHIProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686 + }); + expect(provider.config.lat).toBe(59.3293); + expect(provider.config.lon).toBe(18.0686); + expect(provider.config.precipitationValue).toBe("pmedian"); + }); + + it("should fallback to pmedian for invalid precipitationValue", () => { + const provider = new SMHIProvider({ + precipitationValue: "invalid" + }); + expect(provider.config.precipitationValue).toBe("pmedian"); + }); + + it("should accept valid precipitationValue options", () => { + for (const value of ["pmin", "pmean", "pmedian", "pmax"]) { + const provider = new SMHIProvider({ precipitationValue: value }); + expect(provider.config.precipitationValue).toBe(value); + } + }); + }); + + describe("Coordinate Validation", () => { + it("should limit coordinates to 6 decimal places", async () => { + const provider = new SMHIProvider({ + lat: 59.32930123456789, + lon: 18.06860123456789 + }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + + // After validateCoordinates(config, 6), decimals should be truncated + expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(6); + expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(6); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from timeSeries", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(typeof result.temperature).toBe("number"); + expect(typeof result.windSpeed).toBe("number"); + expect(typeof result.humidity).toBe("number"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should detect precipitation category correctly", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + // Use data with rain (pcat=3 at index 2) + const rainData = JSON.parse(JSON.stringify(smhiData)); + // Make the rain entry the closest to "now" + rainData.timeSeries = [rainData.timeSeries[2]]; + rainData.timeSeries[0].validTime = new Date().toISOString(); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(rainData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.rain).toBe(0.0); // pmedian value with pcat=3 (rain) + expect(result.precipitationAmount).toBe(0.0); + expect(result.snow).toBe(0); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const firstDay = result[0]; + expect(firstDay).toHaveProperty("date"); + expect(firstDay).toHaveProperty("minTemperature"); + expect(firstDay).toHaveProperty("maxTemperature"); + expect(firstDay.minTemperature).toBeLessThanOrEqual(firstDay.maxTemperature); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid data", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json({ invalid: true }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js b/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js new file mode 100644 index 0000000000..7bda9a6b09 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js @@ -0,0 +1,348 @@ +/** + * UK Met Office DataHub Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import hourlyData from "../../../../../mocks/weather_ukmetoffice.json" with { type: "json" }; +import dailyData from "../../../../../mocks/weather_ukmetoffice_daily.json" with { type: "json" }; + +const UKMETOFFICE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly*"; +const UKMETOFFICE_THREE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/three-hourly*"; +const UKMETOFFICE_DAILY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily*"; + +/** + * Returns a copy of the daily mock response with dates shifted to today and future days. + * @param {object} source Source UK Met Office daily mock payload. + * @returns {object} Cloned payload with deterministic future dates in `timeSeries`. + */ +function withFutureDailyTimes (source) { + const clone = JSON.parse(JSON.stringify(source)); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + clone.features[0].properties.timeSeries = clone.features[0].properties.timeSeries.map((day, index) => { + const shiftedDate = new Date(today); + shiftedDate.setUTCDate(today.getUTCDate() + index); + + return { + ...day, + time: `${shiftedDate.toISOString().split("T")[0]}T00:00Z` + }; + }); + + return clone; +} + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("UKMetOfficeDataHubProvider", () => { + let UKMetOfficeDataHubProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/ukmetofficedatahub"); + UKMetOfficeDataHubProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-api-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(51.5); + expect(provider.config.lon).toBe(-0.12); + }); + + it("should error if API key is missing", async () => { + const provider = new UKMetOfficeDataHubProvider({ + lat: 51.5, + lon: -0.12 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Forecast Type Mapping", () => { + it("should use hourly endpoint for current type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_HOURLY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(hourlyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/hourly?"); + }); + + it("should use daily endpoint for forecast type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "forecast" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_DAILY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(dailyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/daily?"); + }); + + it("should use three-hourly endpoint for hourly type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "hourly" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_THREE_HOURLY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(hourlyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/three-hourly?"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from hourly data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBeDefined(); + expect(result.windSpeed).toBeDefined(); + expect(result.humidity).toBeDefined(); + expect(result.weatherType).not.toBeNull(); + }); + + it("should include sunrise/sunset from SunCalc", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert weather code to weather type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.weatherType).toBeTruthy(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_DAILY_URL, () => { + return HttpResponse.json(withFutureDailyTimes(dailyData)); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_THREE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("weatherType"); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherapi_spec.js b/tests/unit/modules/default/weather/providers/weatherapi_spec.js new file mode 100644 index 0000000000..413360f61c --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherapi_spec.js @@ -0,0 +1,311 @@ +/** + * WeatherAPI Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const WEATHER_API_URL = "https://api.weatherapi.com/v1/forecast.json*"; + +/** + * Builds a stable WeatherAPI mock payload for current, daily, and hourly parsing tests. + * @returns {object} WeatherAPI forecast response fixture. + */ +function buildWeatherApiResponse () { + return { + location: { + name: "Toronto", + region: "Ontario", + country: "Canada" + }, + current: { + last_updated_epoch: 4102444800, + temp_c: -2.5, + feelslike_c: -7.1, + humidity: 75, + wind_kph: 18, + wind_degree: 220, + condition: { code: 1003 }, + is_day: 1, + snow_cm: 0.2, + precip_mm: 1.1, + uv: 2 + }, + forecast: { + forecastday: [ + { + date: "2100-01-01", + date_epoch: 4102444800, + astro: { + sunrise: "07:45 AM", + sunset: "05:05 PM" + }, + day: { + mintemp_c: -8, + maxtemp_c: -1, + avgtemp_c: -4, + avghumidity: 81, + totalsnow_cm: 0.6, + totalprecip_mm: 2, + maxwind_kph: 22, + uv: 1, + condition: { code: 1183 } + }, + hour: [ + { + time: "2100-01-01 01:00", + time_epoch: 4102448400, + temp_c: -5, + humidity: 85, + wind_kph: 15, + wind_degree: 210, + wind_dir: "SSW", + condition: { code: 1183 }, + is_day: 0, + snow_cm: 0.1, + precip_mm: 0.5, + will_it_rain: 1, + will_it_snow: 0, + uv: 0 + }, + { + time: "2100-01-01 02:00", + time_epoch: 4102452000, + temp_c: -4.5, + humidity: 83, + wind_kph: 13, + wind_degree: 205, + wind_dir: "SSW", + condition: { code: 1003 }, + is_day: 0, + snow_cm: 0, + precip_mm: 0.2, + will_it_rain: 0, + will_it_snow: 0, + uv: 0 + }, + { + time: "2100-01-01 03:00", + time_epoch: 4102455600, + temp_c: -4, + humidity: 80, + wind_kph: 12, + wind_degree: 200, + wind_dir: "SSW", + condition: { code: 1000 }, + is_day: 0, + snow_cm: 0, + precip_mm: 0, + will_it_rain: 0, + will_it_snow: 0, + uv: 0 + } + ] + }, + { + date: "2100-01-02", + date_epoch: 4102531200, + astro: { + sunrise: "07:44 AM", + sunset: "05:06 PM" + }, + day: { + mintemp_c: -7, + maxtemp_c: 0, + avgtemp_c: -3.3, + avghumidity: 78, + totalsnow_cm: 0, + totalprecip_mm: 0.4, + maxwind_kph: 20, + uv: 2, + condition: { code: 1006 } + }, + hour: [] + } + ] + } + }; +} + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherAPIProvider", () => { + let WeatherAPIProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherapi"); + WeatherAPIProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "current" + }); + expect(provider.config.lat).toBe(43.65); + expect(provider.config.lon).toBe(-79.38); + expect(provider.config.apiKey).toBe("test-key"); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const provider = new WeatherAPIProvider({ apiKey: "test-key" }); + expect(provider.config.apiBase).toBe("https://api.weatherapi.com/v1"); + expect(provider.config.type).toBe("current"); + expect(provider.config.maxEntries).toBe(5); + expect(provider.config.updateInterval).toBe(10 * 60 * 1000); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data correctly", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks(resolve, reject); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse())) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-2.5); + expect(result.feelsLikeTemp).toBe(-7.1); + expect(result.humidity).toBe(75); + expect(result.windSpeed).toBeCloseTo(5, 1); + expect(result.windFromDirection).toBe(220); + expect(result.weatherType).toBe("day-cloudy"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.minTemperature).toBe(-8); + expect(result.maxTemperature).toBe(-1); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data correctly", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "forecast", + maxEntries: 2, + maxNumberOfDays: 2 + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse())) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0].minTemperature).toBe(-8); + expect(result[0].maxTemperature).toBe(-1); + expect(result[0].weatherType).toBe("day-sprinkle"); + expect(result[0].sunrise).toBeInstanceOf(Date); + expect(result[0].sunset).toBeInstanceOf(Date); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data correctly", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "hourly", + maxEntries: 3, + maxNumberOfDays: 1 + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse())) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].temperature).toBe(-5); + expect(result[0].humidity).toBe(85); + expect(result[0].windFromDirection).toBe(210); + expect(result[0].weatherType).toBe("night-sprinkle"); + expect(result[0].precipitationProbability).toBe(50); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid API response", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json({ + location: {}, + current: {}, + forecast: { forecastday: "invalid" } + })) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherbit_spec.js b/tests/unit/modules/default/weather/providers/weatherbit_spec.js new file mode 100644 index 0000000000..8bea45f141 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherbit_spec.js @@ -0,0 +1,247 @@ +/** + * Weatherbit Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import currentData from "../../../../../mocks/weather_weatherbit.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_weatherbit_forecast.json" with { type: "json" }; +import hourlyData from "../../../../../mocks/weather_weatherbit_hourly.json" with { type: "json" }; + +const WEATHERBIT_CURRENT_URL = "https://api.weatherbit.io/v2.0/current*"; +const WEATHERBIT_FORECAST_URL = "https://api.weatherbit.io/v2.0/forecast/daily*"; +const WEATHERBIT_HOURLY_URL = "https://api.weatherbit.io/v2.0/forecast/hourly*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherbitProvider", () => { + let WeatherbitProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherbit"); + WeatherbitProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherbitProvider({ + apiKey: "test-api-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + }); + + it("should error if API key is missing", async () => { + const provider = new WeatherbitProvider({ + lat: 40.71, + lon: -74.0 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(1); + expect(result.windSpeed).toBe(1.5); + expect(result.windFromDirection).toBe(210); + expect(result.humidity).toBe(47); + }); + + it("should parse sunrise/sunset from HH:mm format", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon code to weather type", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.weatherType).not.toBeNull(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + }); + + describe("Hourly Parsing", () => { + it("should handle hourly API endpoint access error", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERBIT_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + + expect(error).toBeDefined(); + expect(error.message || error).toContain("No usable data"); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json({ data: [] }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherflow_spec.js b/tests/unit/modules/default/weather/providers/weatherflow_spec.js new file mode 100644 index 0000000000..2eb2fdb4a4 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherflow_spec.js @@ -0,0 +1,264 @@ +/** + * WeatherFlow Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import weatherflowData from "../../../../../mocks/weather_weatherflow.json" with { type: "json" }; + +const WEATHERFLOW_URL = "https://swd.weatherflow.com/swd/rest/better_forecast*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherFlowProvider", () => { + let WeatherFlowProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherflow"); + WeatherFlowProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + expect(provider.config.token).toBe("test-token"); + expect(provider.config.stationid).toBe("12345"); + }); + + it("should error if token or stationid is missing", async () => { + const provider = new WeatherFlowProvider({}); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("token"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(16); + expect(result.humidity).toBe(28); + expect(result.weatherType).not.toBeNull(); + }); + + it("should convert wind speed from km/h to m/s", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Wind speed 15 km/h -> ~4.17 m/s + expect(result.windSpeed).toBeCloseTo(4.17, 1); + }); + + it("should include sunrise/sunset", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + }); + + it("should aggregate UV data from hourly forecasts", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day should have UV from hourly data + expect(result[0]).toHaveProperty("uvIndex"); + expect(result[0].uvIndex).toBeGreaterThanOrEqual(0); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + // Invalid responses return null without calling error callback + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weathergov_spec.js b/tests/unit/modules/default/weather/providers/weathergov_spec.js new file mode 100644 index 0000000000..6326a7fa4b --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weathergov_spec.js @@ -0,0 +1,442 @@ +/** + * Weather.gov Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Weather.gov is the US National Weather Service API. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import pointsData from "../../../../../mocks/weather_weathergov_points.json" with { type: "json" }; +import stationsData from "../../../../../mocks/weather_weathergov_stations.json" with { type: "json" }; +import currentData from "../../../../../mocks/weather_weathergov_current.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_weathergov_forecast.json" with { type: "json" }; +import hourlyData from "../../../../../mocks/weather_weathergov_hourly.json" with { type: "json" }; + +const WEATHERGOV_POINTS_URL = "https://api.weather.gov/points/*"; +const WEATHERGOV_STATIONS_URL = "https://api.weather.gov/gridpoints/*/stations"; +const WEATHERGOV_CURRENT_URL = "https://api.weather.gov/stations/*/observations/latest"; +const WEATHERGOV_FORECAST_URL = "https://api.weather.gov/gridpoints/*/forecast*"; +const WEATHERGOV_HOURLY_URL = "https://api.weather.gov/gridpoints/*/forecast/hourly*"; + +let server; + +beforeAll(() => { + server = setupServer( + // Default handlers for initialization + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + return HttpResponse.json(stationsData); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); + // Re-add default initialization handlers + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + return HttpResponse.json(stationsData); + }) + ); +}); + +describe("WeatherGovProvider", () => { + let WeatherGovProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weathergov"); + WeatherGovProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + expect(provider.config.type).toBe("current"); + }); + + it("should have default update interval", () => { + const provider = new WeatherGovProvider({}); + expect(provider.config.updateInterval).toBe(600000); // 10 minutes + }); + }); + + describe("Two-Step Initialization", () => { + it("should fetch points URL and then stations URL", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + provider.setCallbacks(vi.fn(), vi.fn()); + + let pointsRequested = false; + let stationsRequested = false; + + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + pointsRequested = true; + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + stationsRequested = true; + return HttpResponse.json(stationsData); + }) + ); + + await provider.initialize(); + + expect(pointsRequested).toBe(true); + expect(stationsRequested).toBe(true); + expect(provider.locationName).toBe("Washington, DC"); + }); + + it("should store forecast URLs after initialization", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0 + }); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + + expect(provider.forecastURL).toContain("forecast?units=si"); + expect(provider.forecastHourlyURL).toContain("forecast/hourly?units=si"); + expect(provider.stationObsURL).toContain("observations/latest"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-1); + expect(result.windSpeed).toBe(0); + expect(result.windFromDirection).toBe(0); + expect(result.humidity).toBe(64); // Rounded from 63.77 + expect(result.weatherType).not.toBeNull(); + }); + + it("should use heat index or wind chill for feels like temperature", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has null windChill - falls back to temperature + expect(result.feelsLikeTemp).toBe(-1); + }); + + it("should include sunrise/sunset", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + + it("should not skip the first day of forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Mock data starts on 2026-02-06 ("This Afternoon"). + // Before the fix, slice(1) dropped today, so result[0] would have been 2026-02-07. + const firstDate = result[0].date; + expect(firstDate.getFullYear()).toBe(2026); + expect(firstDate.getMonth()).toBe(1); // February (0-indexed) + expect(firstDate.getDate()).toBe(6); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(156); // Real API returns 156 hourly periods + expect(result[0]).toHaveProperty("temperature"); + expect(result[0]).toHaveProperty("windSpeed"); + expect(result[0]).toHaveProperty("windFromDirection"); + expect(result[0]).toHaveProperty("weatherType"); + }); + + it("should convert wind direction strings to degrees", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has "S" wind for both hours + expect(result[0].windFromDirection).toBe(180); + // Third hour also has "S" wind + expect(result[2].windFromDirection).toBe(180); + }); + + it("should parse wind speed with units", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Wind speeds should be converted from km/h to m/s + expect(result[0].windSpeed).toBeCloseTo(1.11, 1); // Real data: 4 km/h -> ~1.11 m/s + }); + }); + + describe("Error Handling", () => { + it("should categorize DNS errors as retryable", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.error(); + }) + ); + + await provider.initialize(); + + // Should call error callback + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + + it("should handle invalid JSON response", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json({ properties: null }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error.message).toContain("Invalid"); + }); + }); + + describe("Weather Type Conversion", () => { + it("should convert textDescription to weather types", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + const testData = JSON.parse(JSON.stringify(currentData)); + testData.properties.textDescription = "Thunderstorm"; + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(testData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Thunderstorm should map to day or night thunderstorm + expect(["thunderstorm", "night-thunderstorm"]).toContain(result.weatherType); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/yr_spec.js b/tests/unit/modules/default/weather/providers/yr_spec.js new file mode 100644 index 0000000000..4602a8d840 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/yr_spec.js @@ -0,0 +1,287 @@ +/** + * Yr.no Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Yr.no is the Norwegian Meteorological Institute API. + * + * Uses fake timers to ensure deterministic timeseries selection. + * The provider picks the closest past entry from timeseries based on new Date(). + * Fixed to 2026-02-06T21:30:00Z → selects timeseries[0] at 21:00 with T=-5.8°C. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; + +import yrData from "../../../../../mocks/weather_yr.json" with { type: "json" }; + +const YR_FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/**"; +const YR_SUNRISE_URL = "https://api.met.no/weatherapi/sunrise/**"; + +// Fixed time: 30 minutes after the first timeseries entry (2026-02-06T21:00:00Z) +// This ensures timeseries[0] is always chosen as the closest past entry. +const FAKE_NOW = new Date("2026-02-06T21:30:00Z"); + +let server; + +beforeAll(() => { + server = setupServer( + http.get(({ request }) => request.url.includes("/locationforecast/"), () => { + return HttpResponse.json(yrData); + }), + http.get(({ request }) => request.url.includes("/sunrise/"), () => { + return HttpResponse.json({ + when: { interval: ["2026-02-06T00:00:00+01:00"] }, + properties: { + sunrise: { time: "2026-02-06T08:30:00+01:00" }, + sunset: { time: "2026-02-06T16:30:00+01:00" } + } + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +beforeEach(() => { + vi.useFakeTimers({ now: FAKE_NOW }); +}); + +afterEach(() => { + vi.useRealTimers(); + server.resetHandlers(); +}); + +describe("YrProvider", () => { + let YrProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/yr"); + YrProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + altitude: 94 + }); + expect(provider.config.lat).toBe(59.91); + expect(provider.config.lon).toBe(10.72); + expect(provider.config.altitude).toBe(94); + }); + + it("should enforce minimum 10-minute update interval", () => { + const provider = new YrProvider({ + updateInterval: 60000 // 1 minute - too short + }); + expect(provider.config.updateInterval).toBe(600000); + }); + + it("should allow intervals >= 10 minutes", () => { + const provider = new YrProvider({ + updateInterval: 900000 // 15 minutes + }); + expect(provider.config.updateInterval).toBe(900000); + }); + }); + + describe("Coordinate Validation", () => { + it("should limit coordinates to 4 decimal places", async () => { + const provider = new YrProvider({ + lat: 59.91234567, + lon: 10.72345678 + }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + + expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(4); + expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(4); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from timeseries", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + // With fake time at 21:30, provider selects timeseries[0] (21:00 UTC) + expect(result.temperature).toBe(-5.8); + expect(result.windSpeed).toBe(6.0); + expect(result.windFromDirection).toBe(37.0); + expect(result.humidity).toBe(66.5); + // 21:00 is after sunset (16:30), symbol_code "snow" maps to "snow" + expect(result.weatherType).toBe("snow"); + }); + + it("should include sunrise/sunset from stellar data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.sunset.getTime()).toBeGreaterThan(result.sunrise.getTime()); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day.minTemperature).toBeLessThanOrEqual(day.maxTemperature); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("precipitationAmount"); + expect(hour).toHaveProperty("weatherType"); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json({ properties: {} }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); + + describe("Weather Type Conversion", () => { + it("should convert yr symbol codes correctly", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current", + currentForecastHours: 1 + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + // Uses yrData from beforeAll which has symbol_code "snow" + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // 21:00 is after sunset (16:30), next_1_hours symbol_code is "snow" + expect(result.weatherType).toBe("snow"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/weather_object_spec.js b/tests/unit/modules/default/weather/weather_object_spec.js index 2bc87d78e3..cef65bfe4e 100644 --- a/tests/unit/modules/default/weather/weather_object_spec.js +++ b/tests/unit/modules/default/weather/weather_object_spec.js @@ -1,4 +1,6 @@ -const WeatherObject = require("../../../../../modules/default/weather/weatherobject"); +const defaults = require("../../../../../js/defaults"); + +const WeatherObject = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherobject`); global.moment = require("moment-timezone"); global.SunCalc = require("suncalc"); diff --git a/tests/unit/modules/default/weather/weather_providers_spec.js b/tests/unit/modules/default/weather/weather_providers_spec.js new file mode 100644 index 0000000000..4d226da202 --- /dev/null +++ b/tests/unit/modules/default/weather/weather_providers_spec.js @@ -0,0 +1,189 @@ +/** + * Weather Provider Smoke Tests + * + * Tests basic provider functionality: configuration, callbacks, and validation. + * Parser logic with private methods (#) is validated through live testing. + */ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest"; + +// Mock global fetch for location lookup +const originalFetch = global.fetch; + +global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ city: "Munich", locality: "Munich" }) +})); + +// Restore original fetch after all tests +afterAll(() => { + global.fetch = originalFetch; +}); + +describe("Weather Provider Smoke Tests", () => { + describe("OpenMeteoProvider", () => { + let OpenMeteoProvider; + let provider; + + beforeAll(async () => { + const module = await import("../../../../../defaultmodules/weather/providers/openmeteo"); + OpenMeteoProvider = module.default; + }); + + beforeEach(() => { + provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current", + updateInterval: 600000 + }); + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.type).toBe("current"); + expect(provider.config.updateInterval).toBe(600000); + }); + + it("should have default values", () => { + const defaultProvider = new OpenMeteoProvider({}); + expect(defaultProvider.config.lat).toBe(0); + expect(defaultProvider.config.lon).toBe(0); + expect(defaultProvider.config.type).toBe("current"); + expect(defaultProvider.config.maxNumberOfDays).toBe(5); + }); + + it("should accept all supported types", () => { + expect(new OpenMeteoProvider({ type: "current" }).config.type).toBe("current"); + expect(new OpenMeteoProvider({ type: "forecast" }).config.type).toBe("forecast"); + expect(new OpenMeteoProvider({ type: "hourly" }).config.type).toBe("hourly"); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + + it("should initialize without callbacks", async () => { + await expect(provider.initialize()).resolves.not.toThrow(); + }); + }); + + describe("Public Methods", () => { + it("should have start/stop methods", () => { + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should have initialize method", () => { + expect(typeof provider.initialize).toBe("function"); + }); + + it("should have setCallbacks method", () => { + expect(typeof provider.setCallbacks).toBe("function"); + }); + }); + }); + + describe("OpenWeatherMapProvider", () => { + let OpenWeatherMapProvider; + let provider; + + beforeAll(async () => { + const module = await import("../../../../../defaultmodules/weather/providers/openweathermap"); + OpenWeatherMapProvider = module.default; + }); + + beforeEach(() => { + provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-api-key", + type: "current" + }); + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const defaultProvider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(defaultProvider.config.apiVersion).toBe("3.0"); + expect(defaultProvider.config.weatherEndpoint).toBe("/onecall"); + expect(defaultProvider.config.apiBase).toBe("https://api.openweathermap.org/data/"); + }); + + it("should accept all supported types", () => { + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "current" }).config.type).toBe("current"); + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "forecast" }).config.type).toBe("forecast"); + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "hourly" }).config.type).toBe("hourly"); + }); + }); + + describe("API Key Validation", () => { + it("should call onErrorCallback if no API key provided", async () => { + const noKeyProvider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "" + }); + + const onError = vi.fn(); + noKeyProvider.setCallbacks(vi.fn(), onError); + await noKeyProvider.initialize(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API key is required" + }) + ); + }); + + it("should not create fetcher without API key", async () => { + const noKeyProvider = new OpenWeatherMapProvider({ + apiKey: "" + }); + noKeyProvider.setCallbacks(vi.fn(), vi.fn()); + await noKeyProvider.initialize(); + + expect(noKeyProvider.fetcher).toBeNull(); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + }); + + describe("Public Methods", () => { + it("should have start/stop methods", () => { + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should have initialize method", () => { + expect(typeof provider.initialize).toBe("function"); + }); + + it("should have setCallbacks method", () => { + expect(typeof provider.setCallbacks).toBe("function"); + }); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/weather_utils_spec.js b/tests/unit/modules/default/weather/weather_utils_spec.js index da3979cdb7..5f21121994 100644 --- a/tests/unit/modules/default/weather/weather_utils_spec.js +++ b/tests/unit/modules/default/weather/weather_utils_spec.js @@ -1,5 +1,7 @@ -const weather = require("../../../../../modules/default/weather/weatherutils"); -const WeatherUtils = require("../../../../../modules/default/weather/weatherutils"); +const defaults = require("../../../../../js/defaults"); + +const weather = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherutils`); +const WeatherUtils = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherutils`); describe("Weather utils tests", () => { describe("temperature conversion to imperial", () => { diff --git a/tests/utils/weather_mocker.js b/tests/utils/weather_mocker.js deleted file mode 100644 index c0ebbba1c4..0000000000 --- a/tests/utils/weather_mocker.js +++ /dev/null @@ -1,52 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const exec = require("node:child_process").execSync; - -/** - * @param {string} type what data to read, can be "current" "forecast" or "hourly - * @param {object} extendedData extra data to add to the default mock data - * @returns {string} mocked current weather data - */ -const readMockData = (type, extendedData = {}) => { - let fileName; - - switch (type) { - case "forecast": - fileName = "weather_forecast.json"; - break; - case "hourly": - fileName = "weather_hourly.json"; - break; - case "current": - default: - fileName = "weather_current.json"; - break; - } - - const fileData = JSON.parse(fs.readFileSync(path.resolve(`${__dirname}/../mocks/${fileName}`)).toString()); - const mergedData = JSON.stringify({ ...{}, ...fileData, ...extendedData }); - return mergedData; -}; - -const injectMockData = (configFileName, extendedData = {}) => { - let mockWeather; - if (configFileName.includes("forecast")) { - mockWeather = readMockData("forecast", extendedData); - } else if (configFileName.includes("hourly")) { - mockWeather = readMockData("hourly", extendedData); - } else { - mockWeather = readMockData("current", extendedData); - } - let content = fs.readFileSync(configFileName).toString(); - content = content.replace("#####WEATHERDATA#####", mockWeather); - const tempFile = configFileName.replace(".js", "_temp.js"); - fs.writeFileSync(tempFile, content); - return tempFile; -}; - -const cleanupMockData = () => { - const tempDir = path.resolve(`${__dirname}/../configs`).toString(); - exec(`find ${tempDir} -type f -name *_temp.js -delete`); -}; - -module.exports = { injectMockData, cleanupMockData }; diff --git a/translations/af.json b/translations/af.json index 8fb6468020..8a138c24be 100644 --- a/translations/af.json +++ b/translations/af.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Ongeldige URL.", "MODULE_ERROR_NO_CONNECTION": "Geen internetverbinding.", "MODULE_ERROR_UNAUTHORIZED": "Owerheid het misluk.", + "MODULE_ERROR_RATE_LIMITED": "Te veel versoeke. Probeer later weer.", + "MODULE_ERROR_SERVER_ERROR": "Bediener fout. Probeer later weer.", + "MODULE_ERROR_CLIENT_ERROR": "Versoek het misluk.", "MODULE_ERROR_UNSPECIFIED": "Gaan die logs na vir meer besonderhede.", "NEWSFEED_NO_ITEMS": "Geen nuus op die oomblik.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kan nie hier vertoon word nie.", "UPDATE_NOTIFICATION": "MagicMirror² update beskikbaar.", "UPDATE_NOTIFICATION_MODULE": "Update beskikbaar vir {MODULE_NAME} module.", diff --git a/translations/ar.json b/translations/ar.json index 968d253283..fd2bfb6b05 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -34,9 +34,13 @@ "MODULE_ERROR_MALFORMED_URL": "رابط غير صحيح.", "MODULE_ERROR_NO_CONNECTION": "لا يوجد اتصال بالإنترنت.", "MODULE_ERROR_UNAUTHORIZED": "فشل التصريح.", + "MODULE_ERROR_RATE_LIMITED": "طلبات كثيرة جدا. إعادة المحاولة لاحقا.", + "MODULE_ERROR_SERVER_ERROR": "خطأ في الخادم. إعادة المحاولة لاحقا.", + "MODULE_ERROR_CLIENT_ERROR": "فشل الطلب.", "MODULE_ERROR_UNSPECIFIED": "تحقق من السجلات لمزيد من التفاصيل.", "NEWSFEED_NO_ITEMS": "لا توجد أخبار في الوقت الحالي.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "لا يمكن عرض المقالة هنا.", "UPDATE_NOTIFICATION": "تحديث MagicMirror² متاح.", "UPDATE_NOTIFICATION_MODULE": "تحديث متاح لوحدة {MODULE_NAME}.", diff --git a/translations/bg.json b/translations/bg.json index 3d742f7a4c..018422cbad 100644 --- a/translations/bg.json +++ b/translations/bg.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Неправилен URL адрес.", "MODULE_ERROR_NO_CONNECTION": "Няма интернет връзка.", "MODULE_ERROR_UNAUTHORIZED": "Неуспешна авторизация.", + "MODULE_ERROR_RATE_LIMITED": "Твърде много заявки. Повторен опит по-късно.", + "MODULE_ERROR_SERVER_ERROR": "Грешка в сървъра. Повторен опит по-късно.", + "MODULE_ERROR_CLIENT_ERROR": "Заявката неуспешна.", "MODULE_ERROR_UNSPECIFIED": "Проверете логовете за повече подробности.", "NEWSFEED_NO_ITEMS": "Няма новини в момента.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Статията не може да бъде показана тук.", "UPDATE_NOTIFICATION": "Налична е актуализация за MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Налична е актуализация за модула „{MODULE_NAME}“.", diff --git a/translations/ca.json b/translations/ca.json index 8be470ad1c..1832d2cb9f 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "L'URL és mal format.", "MODULE_ERROR_NO_CONNECTION": "No hi ha connexió a Internet.", "MODULE_ERROR_UNAUTHORIZED": "L'autorització ha fallat.", + "MODULE_ERROR_RATE_LIMITED": "Masses sol·licituds. Reintentant més tard.", + "MODULE_ERROR_SERVER_ERROR": "Error del servidor. Reintentant més tard.", + "MODULE_ERROR_CLIENT_ERROR": "La sol·licitud ha fallat.", "MODULE_ERROR_UNSPECIFIED": "Consulta els registres per a més detalls.", "NEWSFEED_NO_ITEMS": "No hi ha notícies disponibles en aquest moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "L'article no es pot mostrar aquí.", "UPDATE_NOTIFICATION": "MagicMirror² actualizació disponible.", "UPDATE_NOTIFICATION_MODULE": "Disponible una actualizació per al mòdul {MODULE_NAME}.", diff --git a/translations/cs.json b/translations/cs.json index 2f16ab1b6e..72034ace0f 100644 --- a/translations/cs.json +++ b/translations/cs.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Nesprávná URL adresa.", "MODULE_ERROR_NO_CONNECTION": "Není připojení k internetu.", "MODULE_ERROR_UNAUTHORIZED": "Autorizace selhala.", + "MODULE_ERROR_RATE_LIMITED": "Příliš mnoho požadavků. Zkouším znovu později.", + "MODULE_ERROR_SERVER_ERROR": "Chyba serveru. Zkouším znovu později.", + "MODULE_ERROR_CLIENT_ERROR": "Požadavek selhal.", "MODULE_ERROR_UNSPECIFIED": "Zkontrolujte protokoly pro více informací.", "NEWSFEED_NO_ITEMS": "Žádné zprávy.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Článek zde nelze zobrazit.", "UPDATE_NOTIFICATION": "Dostupná aktualizace pro MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Dostupná aktualizace pro modul {MODULE_NAME}.", diff --git a/translations/cv.json b/translations/cv.json index 1138d766db..22e2325ec7 100644 --- a/translations/cv.json +++ b/translations/cv.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "Ҫӗҫ ҫӗнӗ URL хата.", "MODULE_ERROR_NO_CONNECTION": "Интернет-пулла хӗҫҫӗн.", "MODULE_ERROR_UNAUTHORIZED": "Авторизация хата.", + "MODULE_ERROR_RATE_LIMITED": "Нумай ыйту. Хыҫра тепӗр хут.", + "MODULE_ERROR_SERVER_ERROR": "Сервер хатӗ. Хыҫра тепӗр хут.", + "MODULE_ERROR_CLIENT_ERROR": "Ыйту хатӗ.", "MODULE_ERROR_UNSPECIFIED": "Тӗп лог ҫӗнтерӗ.", "NEWSFEED_NO_ITEMS": "Пулас ҫӗнтер ҫук.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Article cannot be displayed here.", "UPDATE_NOTIFICATION": "MagicMirror² валли ҫӗнетӳ пур.", "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} модуль валли ҫӗнетӳ пур.", diff --git a/translations/cy.json b/translations/cy.json index 824d909236..d172aa2d7b 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL anghywir.", "MODULE_ERROR_NO_CONNECTION": "Dim cysylltiad rhyngrwyd.", "MODULE_ERROR_UNAUTHORIZED": "Methiant awdurdodi.", + "MODULE_ERROR_RATE_LIMITED": "Gormod o geisiadau. Yn ceisio eto yn nes ymlaen.", + "MODULE_ERROR_SERVER_ERROR": "Gwall gweinydd. Yn ceisio eto yn nes ymlaen.", + "MODULE_ERROR_CLIENT_ERROR": "Cais wedi methu.", "MODULE_ERROR_UNSPECIFIED": "Gwiriwch y logiau am ragor o fanylion.", "NEWSFEED_NO_ITEMS": "Dim newyddion ar hyn o bryd.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Ni ellir dangos yr erthygl yma.", "UPDATE_NOTIFICATION": "MagicMirror² mwy diweddar yn barod.", "UPDATE_NOTIFICATION_MODULE": "Mae diweddaraiad ar gyfer y modiwl {MODULE_NAME}.", diff --git a/translations/da.json b/translations/da.json index 13486bfc30..6b56913955 100644 --- a/translations/da.json +++ b/translations/da.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Forkert url.", "MODULE_ERROR_NO_CONNECTION": "Ingen internetforbindelse.", "MODULE_ERROR_UNAUTHORIZED": "Godkendelse mislykkedes.", + "MODULE_ERROR_RATE_LIMITED": "For mange anmodninger. Prøver igen senere.", + "MODULE_ERROR_SERVER_ERROR": "Serverfejl. Prøver igen senere.", + "MODULE_ERROR_CLIENT_ERROR": "Anmodning mislykkedes.", "MODULE_ERROR_UNSPECIFIED": "Tjek logfiler for flere detaljer.", "NEWSFEED_NO_ITEMS": "Ingen nyheder i øjeblikket.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artiklen kan ikke vises her.", "UPDATE_NOTIFICATION": "MagicMirror² opdatering tilgængelig.", "UPDATE_NOTIFICATION_MODULE": "Opdatering tilgængelig for {MODULE_NAME} modulet.", diff --git a/translations/de.json b/translations/de.json index 86597b1601..af50e69c4e 100644 --- a/translations/de.json +++ b/translations/de.json @@ -37,9 +37,13 @@ "MODULE_ERROR_MALFORMED_URL": "Fehlerhafte URL.", "MODULE_ERROR_NO_CONNECTION": "Keine Internetverbindung.", "MODULE_ERROR_UNAUTHORIZED": "Autorisierung fehlgeschlagen.", + "MODULE_ERROR_RATE_LIMITED": "Zu viele Anfragen. Erneuter Versuch später.", + "MODULE_ERROR_SERVER_ERROR": "Serverfehler. Erneuter Versuch später.", + "MODULE_ERROR_CLIENT_ERROR": "Anfrage fehlgeschlagen.", "MODULE_ERROR_UNSPECIFIED": "Prüfe die Logdateien für weitere Details.", "NEWSFEED_NO_ITEMS": "Momentan keine Neuigkeiten.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kann hier nicht angezeigt werden.", "UPDATE_NOTIFICATION": "Aktualisierung für MagicMirror² verfügbar.", "UPDATE_NOTIFICATION_MODULE": "Aktualisierung für das Modul „{MODULE_NAME}“ verfügbar.", diff --git a/translations/el.json b/translations/el.json index 2fd12c90e3..9cc161d55e 100644 --- a/translations/el.json +++ b/translations/el.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Λανθασμένη μορφή url.", "MODULE_ERROR_NO_CONNECTION": "Δεν υπάρχει σύνδεση στο διαδίκτυο.", "MODULE_ERROR_UNAUTHORIZED": "Η εξουσιοδότηση απέτυχε.", + "MODULE_ERROR_RATE_LIMITED": "Πάρα πολλά αιτήματα. Θα ξαναπροσπαθήσω αργότερα.", + "MODULE_ERROR_SERVER_ERROR": "Σφάλμα διακομιστή. Θα ξαναπροσπαθήσω αργότερα.", + "MODULE_ERROR_CLIENT_ERROR": "Το αίτημα απέτυχε.", "MODULE_ERROR_UNSPECIFIED": "Ελέγξτε τα αρχεία καταγραφής για περισσότερες λεπτομέρειες.", "NEWSFEED_NO_ITEMS": "Δεν υπάρχουν ειδήσεις αυτή τη στιγμή.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Το άρθρο δεν μπορεί να εμφανιστεί εδώ.", "UPDATE_NOTIFICATION": "Διατίθεται ενημέρωση MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Διατίθεται ενημέρωση για το module {MODULE_NAME}.", diff --git a/translations/en.json b/translations/en.json index c4a2f196df..311d4973a2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "Malformed url.", "MODULE_ERROR_NO_CONNECTION": "No internet connection.", "MODULE_ERROR_UNAUTHORIZED": "Authorization failed.", + "MODULE_ERROR_RATE_LIMITED": "Too many requests. Retrying later.", + "MODULE_ERROR_SERVER_ERROR": "Server error. Retrying later.", + "MODULE_ERROR_CLIENT_ERROR": "Request failed.", "MODULE_ERROR_UNSPECIFIED": "Check logs for more details.", "NEWSFEED_NO_ITEMS": "No news at the moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Article cannot be displayed here.", "UPDATE_NOTIFICATION": "MagicMirror² update available.", "UPDATE_NOTIFICATION_MODULE": "Update available for {MODULE_NAME} module.", diff --git a/translations/eo.json b/translations/eo.json index a5730d8243..e1a9475fe3 100644 --- a/translations/eo.json +++ b/translations/eo.json @@ -37,9 +37,13 @@ "MODULE_ERROR_MALFORMED_URL": "Malĝusta URL.", "MODULE_ERROR_NO_CONNECTION": "Neniu interreta konekto.", "MODULE_ERROR_UNAUTHORIZED": "Aŭtorigo malsukcesis.", + "MODULE_ERROR_RATE_LIMITED": "Tro multaj petoj. Reprovo poste.", + "MODULE_ERROR_SERVER_ERROR": "Servila eraro. Reprovo poste.", + "MODULE_ERROR_CLIENT_ERROR": "Peto malsukcesis.", "MODULE_ERROR_UNSPECIFIED": "Kontrolu la protokolajn dosierojn por pli da detaloj.", "NEWSFEED_NO_ITEMS": "Momente neniu novaĵoj.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikolo ne estas montrebla ĉi tie.", "UPDATE_NOTIFICATION": "Ĝisdatigo por MagicMirror² disponebla.", "UPDATE_NOTIFICATION_MODULE": "Ĝisdatigo por la modulo „{MODULE_NAME}“ disponebla.", diff --git a/translations/es.json b/translations/es.json index 4106d45d74..f157d35512 100644 --- a/translations/es.json +++ b/translations/es.json @@ -37,9 +37,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL mal formado.", "MODULE_ERROR_NO_CONNECTION": "No hay conexión a Internet.", "MODULE_ERROR_UNAUTHORIZED": "No autorizado.", + "MODULE_ERROR_RATE_LIMITED": "Demasiadas solicitudes. Reintentando más tarde.", + "MODULE_ERROR_SERVER_ERROR": "Error del servidor. Reintentando más tarde.", + "MODULE_ERROR_CLIENT_ERROR": "La solicitud falló.", "MODULE_ERROR_UNSPECIFIED": "Por favor, consulte los registros para obtener más información.", "NEWSFEED_NO_ITEMS": "No hay noticias disponibles en este momento.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "El artículo no se puede mostrar aquí.", "UPDATE_NOTIFICATION": "MagicMirror² actualización disponible.", "UPDATE_NOTIFICATION_MODULE": "Disponible una actualización para el módulo {MODULE_NAME}.", diff --git a/translations/et.json b/translations/et.json index cd3d5ebe7a..e0777983d6 100644 --- a/translations/et.json +++ b/translations/et.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Ebakorrektne url.", "MODULE_ERROR_NO_CONNECTION": "Interneti ühendus puudub.", "MODULE_ERROR_UNAUTHORIZED": "Autoriseerimine ebaõnnestus.", + "MODULE_ERROR_RATE_LIMITED": "Liiga palju päringuid. Proovin hiljem uuesti.", + "MODULE_ERROR_SERVER_ERROR": "Serveri viga. Proovin hiljem uuesti.", + "MODULE_ERROR_CLIENT_ERROR": "Päring ebaõnnestus.", "MODULE_ERROR_UNSPECIFIED": "Lisateabe saamiseks kontrollige logifaile.", "NEWSFEED_NO_ITEMS": "Hetkel ei ole uudiseid.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artiklit ei saa siin kuvada.", "UPDATE_NOTIFICATION": "MagicMirror²'le on uuendus saadaval.", "UPDATE_NOTIFICATION_MODULE": "Uuendus saadaval {MODULE_NAME} moodulile.", diff --git a/translations/fi.json b/translations/fi.json index c29ae33310..b6e2d303c7 100644 --- a/translations/fi.json +++ b/translations/fi.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Virheellinen url.", "MODULE_ERROR_NO_CONNECTION": "Ei internet-yhteyttä.", "MODULE_ERROR_UNAUTHORIZED": "Valtuutus epäonnistui.", + "MODULE_ERROR_RATE_LIMITED": "Liikaa pyyntöjä. Yritetään myöhemmin uudelleen.", + "MODULE_ERROR_SERVER_ERROR": "Palvelinvirhe. Yritetään myöhemmin uudelleen.", + "MODULE_ERROR_CLIENT_ERROR": "Pyyntö epäonnistui.", "MODULE_ERROR_UNSPECIFIED": "Tarkista lokitiedostot saadaksesi lisätietoja.", "NEWSFEED_NO_ITEMS": "Ei uutisia tällä hetkellä.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikkelia ei voi näyttää täällä.", "UPDATE_NOTIFICATION": "MagicMirror² päivitys saatavilla.", "UPDATE_NOTIFICATION_MODULE": "Päivitys saatavilla moduulille {MODULE_NAME}.", diff --git a/translations/fr.json b/translations/fr.json index 226f454fcf..47c9369b78 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -37,9 +37,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL mal formée.", "MODULE_ERROR_NO_CONNECTION": "Pas de connexion Internet.", "MODULE_ERROR_UNAUTHORIZED": "L'autorisation à échouée.", + "MODULE_ERROR_RATE_LIMITED": "Trop de requêtes. Nouvelle tentative plus tard.", + "MODULE_ERROR_SERVER_ERROR": "Erreur du serveur. Nouvelle tentative plus tard.", + "MODULE_ERROR_CLIENT_ERROR": "La requête a échoué.", "MODULE_ERROR_UNSPECIFIED": "Consultez les journaux pour plus de détails.", "NEWSFEED_NO_ITEMS": "Aucune nouvelle pour le moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "L'article ne peut pas être affiché ici.", "UPDATE_NOTIFICATION": "Une mise à jour de MagicMirror² est disponible", "UPDATE_NOTIFICATION_MODULE": "Une mise à jour est disponible pour le module {MODULE_NAME}.", diff --git a/translations/fy.json b/translations/fy.json index b261bd1cfd..01d0133bed 100644 --- a/translations/fy.json +++ b/translations/fy.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "De URL is net jildich.", "MODULE_ERROR_NO_CONNECTION": "Gjin ynternetferbining.", "MODULE_ERROR_UNAUTHORIZED": "Autorisearje mislearre.", + "MODULE_ERROR_RATE_LIMITED": "Te folle fersiken. Letter opnij besykje.", + "MODULE_ERROR_SERVER_ERROR": "Tsjinnerfout. Letter opnij besykje.", + "MODULE_ERROR_CLIENT_ERROR": "Fersyk mislearre.", "MODULE_ERROR_UNSPECIFIED": "Sjoch de logs foar mear ynformaasje.", "NEWSFEED_NO_ITEMS": "Op it stuit gjin nijsberjochten.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kin hjir net werjûn wurde.", "UPDATE_NOTIFICATION": "Der is in update beskikber foar MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Der is in update beskikber foar it {MODULE_NAME} module.", diff --git a/translations/gl.json b/translations/gl.json index 8225a1a4c4..217029b537 100644 --- a/translations/gl.json +++ b/translations/gl.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL mal formado.", "MODULE_ERROR_NO_CONNECTION": "Non hai conexión a Internet.", "MODULE_ERROR_UNAUTHORIZED": "A autorización fallou.", + "MODULE_ERROR_RATE_LIMITED": "Demasiadas solicitudes. Reintentando máis tarde.", + "MODULE_ERROR_SERVER_ERROR": "Erro do servidor. Reintentando máis tarde.", + "MODULE_ERROR_CLIENT_ERROR": "A solicitude fallou.", "MODULE_ERROR_UNSPECIFIED": "Verifique os rexistros para obter máis información.", "NEWSFEED_NO_ITEMS": "Non hai novas no momento.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "O artigo non se pode mostrar aquí.", "UPDATE_NOTIFICATION": "Actualización dispoñible para MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Actualización dispoñible para o módulo {MODULE_NAME}.", diff --git a/translations/gu.json b/translations/gu.json index 7b55ee9078..bbd5fb5a5a 100644 --- a/translations/gu.json +++ b/translations/gu.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "ખોટી URL.", "MODULE_ERROR_NO_CONNECTION": "ઇન્ટરનેટ કનેક્શન નથી.", "MODULE_ERROR_UNAUTHORIZED": "અધિકૃત કરવું નિષ્ફળ.", + "MODULE_ERROR_RATE_LIMITED": "ઘણી બધી વિનંતીઓ. પછીથી પુનઃપ્રયાસ.", + "MODULE_ERROR_SERVER_ERROR": "સર્વર ભૂલ. પછીથી પુનઃપ્રયાસ.", + "MODULE_ERROR_CLIENT_ERROR": "વિનંતી નિષ્ફળ.", "MODULE_ERROR_UNSPECIFIED": "વધુ વિગતો માટે લોગ તપાસો.", "NEWSFEED_NO_ITEMS": "હાલમાં કોઈ સમાચાર નથી.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "લેખ અહીં પ્રદર્શિત કરી શકાતો નથી.", "UPDATE_NOTIFICATION": "MagicMirror² અપડેટ ઉપલબ્ધ છે.", "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} મોડ્યુલ માટે અપડેટ ઉપલબ્ધ છે.", diff --git a/translations/he.json b/translations/he.json index 6acd490f4a..45c440ea26 100644 --- a/translations/he.json +++ b/translations/he.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "כתובת אתר לא תקינה.", "MODULE_ERROR_NO_CONNECTION": "אין חיבור לאינטרנט.", "MODULE_ERROR_UNAUTHORIZED": "הזדהות נכשלה.", + "MODULE_ERROR_RATE_LIMITED": "יותר מדי בקשות. מנסה שוב מאוחר יותר.", + "MODULE_ERROR_SERVER_ERROR": "שגיאת שרת. מנסה שוב מאוחר יותר.", + "MODULE_ERROR_CLIENT_ERROR": "הבקשה נכשלה.", "MODULE_ERROR_UNSPECIFIED": "בדוק את היומנים לפרטים נוספים.", "NEWSFEED_NO_ITEMS": "אין חדשות כרגע.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "לא ניתן להציג את המאמר כאן.", "UPDATE_NOTIFICATION": "עדכון זמין ל-MagicMirror²", "UPDATE_NOTIFICATION_MODULE": "עדכון זמין ב-{MODULE_NAME} מודול", diff --git a/translations/hi.json b/translations/hi.json index 725a8a89f8..a488e397b5 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "गलत URL।", "MODULE_ERROR_NO_CONNECTION": "कोई इंटरनेट कनेक्शन नहीं।", "MODULE_ERROR_UNAUTHORIZED": "प्राधिकरण विफल।", + "MODULE_ERROR_RATE_LIMITED": "बहुत सारे अनुरोध। बाद में पुनः प्रयास करना।", + "MODULE_ERROR_SERVER_ERROR": "सर्वर त्रुटि। बाद में पुनः प्रयास करना।", + "MODULE_ERROR_CLIENT_ERROR": "अनुरोध विफल रहा।", "MODULE_ERROR_UNSPECIFIED": "अधिक जानकारी के लिए लॉग जांचें।", "NEWSFEED_NO_ITEMS": "इस समय कोई समाचार नहीं।", + "NEWSFEED_ARTICLE_UNAVAILABLE": "लेख यहाँ प्रदर्शित नहीं किया जा सकता।", "UPDATE_NOTIFICATION": "MagicMirror² अपडेट उपलब्ध।", "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} मॉड्यूल के लिए उपलब्ध अद्यतन।", diff --git a/translations/hr.json b/translations/hr.json index 8964aeaa95..93d59c967e 100644 --- a/translations/hr.json +++ b/translations/hr.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Neispravan URL.", "MODULE_ERROR_NO_CONNECTION": "Nema internetske veze.", "MODULE_ERROR_UNAUTHORIZED": "Autorizacija nije uspjela.", + "MODULE_ERROR_RATE_LIMITED": "Previše zahtjeva. Ponovni pokušaj kasnije.", + "MODULE_ERROR_SERVER_ERROR": "Greška poslužitelja. Ponovni pokušaj kasnije.", + "MODULE_ERROR_CLIENT_ERROR": "Zahtjev nije uspio.", "MODULE_ERROR_UNSPECIFIED": "Provjerite dnevnike za više informacija.", "NEWSFEED_NO_ITEMS": "Trenutno nema vijesti.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Članak se ne može prikazati ovdje.", "UPDATE_NOTIFICATION": "Dostupna je aktualizacija MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Dostupna je aktualizacija modula {MODULE_NAME}.", diff --git a/translations/hu.json b/translations/hu.json index 2c31349b7b..e8b6b12028 100644 --- a/translations/hu.json +++ b/translations/hu.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "Hibás URL.", "MODULE_ERROR_NO_CONNECTION": "Nincs internetkapcsolat.", "MODULE_ERROR_UNAUTHORIZED": "Azonosítás sikertelen.", + "MODULE_ERROR_RATE_LIMITED": "Túl sok kérés. Újrapróbálkozás később.", + "MODULE_ERROR_SERVER_ERROR": "Szerverhiba. Újrapróbálkozás később.", + "MODULE_ERROR_CLIENT_ERROR": "A kérés meghiúsult.", "MODULE_ERROR_UNSPECIFIED": "Ellenőrizze a naplókat további részletekért.", "NEWSFEED_NO_ITEMS": "Jelenleg nincsenek hírek.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "A cikk itt nem jeleníthető meg.", "UPDATE_NOTIFICATION": "MagicMirror²-hoz frissítés érhető el.", "UPDATE_NOTIFICATION_MODULE": "A {MODULE_NAME} modulhoz frissítés érhető el.", diff --git a/translations/id.json b/translations/id.json index 4bb83eb93c..738bd940da 100644 --- a/translations/id.json +++ b/translations/id.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL tidak valid.", "MODULE_ERROR_NO_CONNECTION": "Tidak ada koneksi internet.", "MODULE_ERROR_UNAUTHORIZED": "Gagal otentikasi.", + "MODULE_ERROR_RATE_LIMITED": "Terlalu banyak permintaan. Mencoba lagi nanti.", + "MODULE_ERROR_SERVER_ERROR": "Kesalahan server. Mencoba lagi nanti.", + "MODULE_ERROR_CLIENT_ERROR": "Permintaan gagal.", "MODULE_ERROR_UNSPECIFIED": "Silakan periksa log untuk informasi lebih lanjut.", "NEWSFEED_NO_ITEMS": "Saat ini tidak ada berita.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel tidak dapat ditampilkan di sini.", "UPDATE_NOTIFICATION": "Memperbarui MagicMirror² tersedia.", "UPDATE_NOTIFICATION_MODULE": "Memperbarui tersedia untuk modul {MODULE_NAME}.", diff --git a/translations/is.json b/translations/is.json index d664a9f284..e21d6efb73 100644 --- a/translations/is.json +++ b/translations/is.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Villa í slóð.", "MODULE_ERROR_NO_CONNECTION": "Engin nettenging.", "MODULE_ERROR_UNAUTHORIZED": "Auðkenning mistókst.", + "MODULE_ERROR_RATE_LIMITED": "Of margar beiðnir. Reyni aftur síðar.", + "MODULE_ERROR_SERVER_ERROR": "Villa í þjóni. Reyni aftur síðar.", + "MODULE_ERROR_CLIENT_ERROR": "Beiðni mistókst.", "MODULE_ERROR_UNSPECIFIED": "Vinsamlegast athugaðu skráningu fyrir frekari upplýsingar.", "NEWSFEED_NO_ITEMS": "Engar fréttir í boði núna.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Grein er ekki hægt að sýna hér.", "UPDATE_NOTIFICATION": "MagicMirror² uppfærsla í boði.", "UPDATE_NOTIFICATION_MODULE": "Uppfærsla í boði fyrir {MODULE_NAME} module.", diff --git a/translations/it.json b/translations/it.json index 78051638ee..7c4591e4bf 100644 --- a/translations/it.json +++ b/translations/it.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL non valido.", "MODULE_ERROR_NO_CONNECTION": "Nessuna connessione a Internet.", "MODULE_ERROR_UNAUTHORIZED": "Autenticazione non riuscita.", + "MODULE_ERROR_RATE_LIMITED": "Troppe richieste. Riprovo più tardi.", + "MODULE_ERROR_SERVER_ERROR": "Errore del server. Riprovo più tardi.", + "MODULE_ERROR_CLIENT_ERROR": "Richiesta fallita.", "MODULE_ERROR_UNSPECIFIED": "Si prega di controllare i log per ulteriori dettagli.", "NEWSFEED_NO_ITEMS": "Al momento non ci sono notizie.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "L'articolo non può essere visualizzato qui.", "UPDATE_NOTIFICATION": "E' disponibile un aggiornamento di MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "E' disponibile un aggiornamento del modulo {MODULE_NAME}.", diff --git a/translations/ja.json b/translations/ja.json index e74da77cbc..22052f014b 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "不正なURLです。", "MODULE_ERROR_NO_CONNECTION": "インターネット接続がありません。", "MODULE_ERROR_UNAUTHORIZED": "認証に失敗しました。", + "MODULE_ERROR_RATE_LIMITED": "リクエストが多すぎます。後で再試行します。", + "MODULE_ERROR_SERVER_ERROR": "サーバーエラー。後で再試行します。", + "MODULE_ERROR_CLIENT_ERROR": "リクエストが失敗しました。", "MODULE_ERROR_UNSPECIFIED": "詳細はログを確認してください。", "NEWSFEED_NO_ITEMS": "現在ニュースはありません。", + "NEWSFEED_ARTICLE_UNAVAILABLE": "この記事はここでは表示できません。", "UPDATE_NOTIFICATION": "MagicMirror² のアップデートが利用可能です。", "UPDATE_NOTIFICATION_MODULE": "モジュール {MODULE_NAME} のアップデートが利用可能です。", diff --git a/translations/ko.json b/translations/ko.json index 0e6bbd5ed5..9ee81b2edb 100644 --- a/translations/ko.json +++ b/translations/ko.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "잘못된 URL 형식입니다.", "MODULE_ERROR_NO_CONNECTION": "인터넷이 연결되지 않았습니다.", "MODULE_ERROR_UNAUTHORIZED": "인증이 실패했습니다.", + "MODULE_ERROR_RATE_LIMITED": "요청이 너무 많습니다. 나중에 다시 시도합니다.", + "MODULE_ERROR_SERVER_ERROR": "서버 오류. 나중에 다시 시도합니다.", + "MODULE_ERROR_CLIENT_ERROR": "요청 실패.", "MODULE_ERROR_UNSPECIFIED": "상세 내용은 로그를 확인하세요.", "NEWSFEED_NO_ITEMS": "현재 뉴스가 없습니다.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "이 기사는 여기에 표시할 수 없습니다.", "UPDATE_NOTIFICATION": "새로운 MagicMirror² 업데이트가 있습니다.", "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} 모듈에서 사용 가능한 업데이트 입니다.", diff --git a/translations/lt.json b/translations/lt.json index b922b5193e..cfc5353b02 100644 --- a/translations/lt.json +++ b/translations/lt.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Netinkama URL nuoroda.", "MODULE_ERROR_NO_CONNECTION": "Nėra interneto ryšio.", "MODULE_ERROR_UNAUTHORIZED": "Autorizacija nepavyko.", + "MODULE_ERROR_RATE_LIMITED": "Per daug užklausų. Bandoma vėl vėliau.", + "MODULE_ERROR_SERVER_ERROR": "Serverio klaida. Bandoma vėl vėliau.", + "MODULE_ERROR_CLIENT_ERROR": "Užklausa nepavyko.", "MODULE_ERROR_UNSPECIFIED": "Patikrinkite žurnalus, kad gautumėte daugiau informacijos.", "NEWSFEED_NO_ITEMS": "Šiuo metu naujienų nėra.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Straipsnis čia negali būti rodomas.", "UPDATE_NOTIFICATION": "Galimas MagicMirror² naujinimas.", "UPDATE_NOTIFICATION_MODULE": "Galimas {MODULE_NAME} naujinimas.", diff --git a/translations/ms-my.json b/translations/ms-my.json index d3e7a9565f..e93dd8cdf1 100644 --- a/translations/ms-my.json +++ b/translations/ms-my.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL tidak sah.", "MODULE_ERROR_NO_CONNECTION": "Tiada sambungan internet.", "MODULE_ERROR_UNAUTHORIZED": "Kebenaran gagal.", + "MODULE_ERROR_RATE_LIMITED": "Terlalu banyak permintaan. Cuba lagi nanti.", + "MODULE_ERROR_SERVER_ERROR": "Ralat pelayan. Cuba lagi nanti.", + "MODULE_ERROR_CLIENT_ERROR": "Permintaan gagal.", "MODULE_ERROR_UNSPECIFIED": "Sila semak log untuk maklumat lanjut.", "NEWSFEED_NO_ITEMS": "Tiada berita buat masa ini.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel tidak dapat dipaparkan di sini.", "UPDATE_NOTIFICATION": "MagicMirror² mempunyai update terkini.", "UPDATE_NOTIFICATION_MODULE": "Modul {MODULE_NAME} mempunyai update terkini.", diff --git a/translations/nb.json b/translations/nb.json index 8eac3eec0f..d459ff90ba 100644 --- a/translations/nb.json +++ b/translations/nb.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Ugyldig URL.", "MODULE_ERROR_NO_CONNECTION": "Ingen internettforbindelse.", "MODULE_ERROR_UNAUTHORIZED": "Autentisering mislyktes.", + "MODULE_ERROR_RATE_LIMITED": "For mange forespørsler. Prøver igjen senere.", + "MODULE_ERROR_SERVER_ERROR": "Serverfeil. Prøver igjen senere.", + "MODULE_ERROR_CLIENT_ERROR": "Forespørselen mislyktes.", "MODULE_ERROR_UNSPECIFIED": "Vennligst sjekk loggene for mer informasjon.", "NEWSFEED_NO_ITEMS": "Ingen nyheter tilgjengelig for øyeblikket.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikkelen kan ikke vises her.", "UPDATE_NOTIFICATION": "MagicMirror²-oppdatering er tilgjengelig.", "UPDATE_NOTIFICATION_MODULE": "Oppdatering tilgjengelig for modulen {MODULE_NAME}.", diff --git a/translations/nl.json b/translations/nl.json index 26d55fab95..6102c1de54 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Ongeldige url.", "MODULE_ERROR_NO_CONNECTION": "Geen internet verbinding.", "MODULE_ERROR_UNAUTHORIZED": "Authenticatie mislukt.", + "MODULE_ERROR_RATE_LIMITED": "Te veel verzoeken. Later opnieuw proberen.", + "MODULE_ERROR_SERVER_ERROR": "Serverfout. Later opnieuw proberen.", + "MODULE_ERROR_CLIENT_ERROR": "Verzoek mislukt.", "MODULE_ERROR_UNSPECIFIED": "Bekijk de logs voor meer informatie.", "NEWSFEED_NO_ITEMS": "Geen nieuws op dit moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kan hier niet worden weergegeven.", "UPDATE_NOTIFICATION": "MagicMirror² update beschikbaar.", "UPDATE_NOTIFICATION_MODULE": "Update beschikbaar voor {MODULE_NAME} module.", diff --git a/translations/nn.json b/translations/nn.json index ce97e29bf7..8706cc198b 100644 --- a/translations/nn.json +++ b/translations/nn.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Ugyldig URL.", "MODULE_ERROR_NO_CONNECTION": "Ingen internettforbindelse.", "MODULE_ERROR_UNAUTHORIZED": "Autentisering mislyktes.", + "MODULE_ERROR_RATE_LIMITED": "For mange førespurnader. Prøvar igjen seinare.", + "MODULE_ERROR_SERVER_ERROR": "Serverfeil. Prøvar igjen seinare.", + "MODULE_ERROR_CLIENT_ERROR": "Førespurnaden feila.", "MODULE_ERROR_UNSPECIFIED": "Vennligst sjekk loggfilene for meir informasjon.", "NEWSFEED_NO_ITEMS": "Ingen nyhende tilgjengeleg no.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikkelen kan ikkje visast her.", "UPDATE_NOTIFICATION": "MagicMirror² oppdatering er tilgjengeleg.", "UPDATE_NOTIFICATION_MODULE": "Oppdatering tilgjengeleg for modulen {MODULE_NAME}.", diff --git a/translations/pl.json b/translations/pl.json index f12bde0abb..a74143b62c 100644 --- a/translations/pl.json +++ b/translations/pl.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Nieprawidłowy adres URL.", "MODULE_ERROR_NO_CONNECTION": "Brak połączenia z internetem.", "MODULE_ERROR_UNAUTHORIZED": "Autoryzacja nie powiodła się.", + "MODULE_ERROR_RATE_LIMITED": "Zbyt wiele żądań. Ponowna próba później.", + "MODULE_ERROR_SERVER_ERROR": "Błąd serwera. Ponowna próba później.", + "MODULE_ERROR_CLIENT_ERROR": "Żądanie nie powiodło się.", "MODULE_ERROR_UNSPECIFIED": "Sprawdź logi, aby uzyskać więcej informacji.", "NEWSFEED_NO_ITEMS": "Brak wiadomości w tej chwili.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artykuł nie może być wyświetlony tutaj.", "UPDATE_NOTIFICATION": "Dostępna jest aktualizacja MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Dostępna jest aktualizacja modułu {MODULE_NAME}.", diff --git a/translations/ps.json b/translations/ps.json index 701c30e82a..d9390dcc0c 100644 --- a/translations/ps.json +++ b/translations/ps.json @@ -34,5 +34,8 @@ "UPDATE_NOTIFICATION": "د MagicMirror² نوې نسخه سته ", "UPDATE_NOTIFICATION_MODULE": "د {MODULE_NAME} نوی نسخه سته", "UPDATE_INFO_SINGLE": "اوسنی برخه {COMMIT_COUNT} د {BRANCH_NAME} څخه وروسته پاته ده", - "UPDATE_INFO_MULTIPLE": "اوسنی برخه {COMMIT_COUNT} د {BRANCH_NAME} څخه وروسته پاته ده" + "UPDATE_INFO_MULTIPLE": "اوسنی برخه {COMMIT_COUNT} د {BRANCH_NAME} څخه وروسته پاته ده", + + "NEWSFEED_NO_ITEMS": "مقاله شتون نلري.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "مقاله دلته نه شي ښودل کیدی." } diff --git a/translations/pt-br.json b/translations/pt-br.json index 1a7ecafcb0..4fdf6044e7 100644 --- a/translations/pt-br.json +++ b/translations/pt-br.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL inválido.", "MODULE_ERROR_NO_CONNECTION": "Sem conexão com a Internet.", "MODULE_ERROR_UNAUTHORIZED": "Falha na autenticação.", + "MODULE_ERROR_RATE_LIMITED": "Muitas requisições. Tentando novamente mais tarde.", + "MODULE_ERROR_SERVER_ERROR": "Erro do servidor. Tentando novamente mais tarde.", + "MODULE_ERROR_CLIENT_ERROR": "Requisição falhou.", "MODULE_ERROR_UNSPECIFIED": "Verifique os logs para mais detalhes.", "NEWSFEED_NO_ITEMS": "Atualmente não há notícias.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "O artigo não pode ser exibido aqui.", "UPDATE_NOTIFICATION": "Nova atualização para MagicMirror² disponível.", "UPDATE_NOTIFICATION_MODULE": "Atualização para o módulo {MODULE_NAME} disponível.", diff --git a/translations/pt.json b/translations/pt.json index c6c6105c84..44770c7133 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -37,19 +37,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL inválido.", "MODULE_ERROR_NO_CONNECTION": "Sem ligação à internet.", "MODULE_ERROR_UNAUTHORIZED": "Falha na autorização.", + "MODULE_ERROR_RATE_LIMITED": "Demasiados pedidos. A tentar novamente mais tarde.", + "MODULE_ERROR_SERVER_ERROR": "Erro do servidor. A tentar novamente mais tarde.", + "MODULE_ERROR_CLIENT_ERROR": "Pedido falhou.", "MODULE_ERROR_UNSPECIFIED": "Consulta os registos para mais detalhes.", "NEWSFEED_NO_ITEMS": "Sem notícias de momento.", - - "UPDATE_NOTIFICATION": "Está disponível uma atualização do MagicMirror².", - "UPDATE_NOTIFICATION_MODULE": "Atualização disponível para o módulo {MODULE_NAME}.", - "UPDATE_INFO_SINGLE": "A instalação atual está {COMMIT_COUNT} commit atrás na ramificação {BRANCH_NAME}.", - "UPDATE_INFO_MULTIPLE": "A instalação atual está {COMMIT_COUNT} commits atrás na ramificação {BRANCH_NAME}.", - "UPDATE_NOTIFICATION_DONE": "Atualização concluída do módulo {MODULE_NAME}.", - "UPDATE_NOTIFICATION_ERROR": "Erro na atualização do módulo {MODULE_NAME}.", - "UPDATE_NOTIFICATION_NEED-RESTART": "É necessário reiniciar o MagicMirror.", - - "MODULE_ERROR_MALFORMED_URL": "URL Inválido.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "O artigo não pode ser apresentado aqui.", "UPDATE_NOTIFICATION": "Está disponível uma atualização do MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Atualização disponível para o módulo {MODULE_NAME}.", diff --git a/translations/ro.json b/translations/ro.json index 1dfa6f5d5e..5119fecefb 100644 --- a/translations/ro.json +++ b/translations/ro.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL incorect.", "MODULE_ERROR_NO_CONNECTION": "Fără conexiune la internet.", "MODULE_ERROR_UNAUTHORIZED": "Autorizarea a eșuat.", + "MODULE_ERROR_RATE_LIMITED": "Prea multe cereri. Se reîncearcă mai târziu.", + "MODULE_ERROR_SERVER_ERROR": "Eroare de server. Se reîncearcă mai târziu.", + "MODULE_ERROR_CLIENT_ERROR": "Cererea a eșuat.", "MODULE_ERROR_UNSPECIFIED": "Verificați jurnalele pentru mai multe detalii.", "NEWSFEED_NO_ITEMS": "Nu există știri în acest moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Articolul nu poate fi afișat aici.", "UPDATE_NOTIFICATION": "Un update este disponibil pentru MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Un update este disponibil pentru modulul {MODULE_NAME}.", diff --git a/translations/ru.json b/translations/ru.json index fde2fe81e2..bf5c969a04 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Неверный URL.", "MODULE_ERROR_NO_CONNECTION": "Нет интернет-соединения.", "MODULE_ERROR_UNAUTHORIZED": "Не удалось авторизоваться.", + "MODULE_ERROR_RATE_LIMITED": "Слишком много запросов. Повторная попытка позже.", + "MODULE_ERROR_SERVER_ERROR": "Ошибка сервера. Повторная попытка позже.", + "MODULE_ERROR_CLIENT_ERROR": "Запрос не удался.", "MODULE_ERROR_UNSPECIFIED": "Пожалуйста, проверьте логи для получения дополнительной информации.", "NEWSFEED_NO_ITEMS": "В данный момент нет новостей.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Статья не может быть отображена здесь.", "UPDATE_NOTIFICATION": "Есть обновление для MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Есть обновление для {MODULE_NAME} модуля.", diff --git a/translations/sk.json b/translations/sk.json index 1736d0cc3c..a420a45bbf 100644 --- a/translations/sk.json +++ b/translations/sk.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Nesprávna URL adresa.", "MODULE_ERROR_NO_CONNECTION": "Nie je pripojenie k internetu.", "MODULE_ERROR_UNAUTHORIZED": "Autorizácia zlyhala.", + "MODULE_ERROR_RATE_LIMITED": "Príliš veľa požiadaviek. Skúšam znovu neskôr.", + "MODULE_ERROR_SERVER_ERROR": "Chyba servera. Skúšam znovu neskôr.", + "MODULE_ERROR_CLIENT_ERROR": "Požiadavka zlyhala.", "MODULE_ERROR_UNSPECIFIED": "Skontrolujte protokoly pre viac informácií.", "NEWSFEED_NO_ITEMS": "Momentálne žiadne správy.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Článok sa tu nedá zobraziť.", "UPDATE_NOTIFICATION": "Dostupná aktualizácia pre MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Dostupná aktualizácia pre modul {MODULE_NAME}.", diff --git a/translations/sv.json b/translations/sv.json index 5a6961ffa4..a05c1b7275 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Felaktig URL.", "MODULE_ERROR_NO_CONNECTION": "Ingen internetanslutning.", "MODULE_ERROR_UNAUTHORIZED": "Autentisering misslyckades.", + "MODULE_ERROR_RATE_LIMITED": "För många förfrågningar. Försöker igen senare.", + "MODULE_ERROR_SERVER_ERROR": "Serverfel. Försöker igen senare.", + "MODULE_ERROR_CLIENT_ERROR": "Förfrågan misslyckades.", "MODULE_ERROR_UNSPECIFIED": "Vänligen kontrollera loggarna för mer information.", "NEWSFEED_NO_ITEMS": "Inga nyheter för tillfället.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikeln kan inte visas här.", "UPDATE_NOTIFICATION": "MagicMirror² uppdatering finns tillgänglig.", "UPDATE_NOTIFICATION_MODULE": "Uppdatering finns tillgänglig av {MODULE_NAME} modulen.", diff --git a/translations/th.json b/translations/th.json index 6a9965ac45..1a2bfd40bb 100644 --- a/translations/th.json +++ b/translations/th.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL ผิดรูปแบบ", "MODULE_ERROR_NO_CONNECTION": "ไม่มีการเชื่อมต่ออินเทอร์เน็ต.", "MODULE_ERROR_UNAUTHORIZED": "การอนุญาตล้มเหลว", + "MODULE_ERROR_RATE_LIMITED": "คำขอมากเกินไป กำลังลองใหม่ในภายหลัง", + "MODULE_ERROR_SERVER_ERROR": "ข้อผิดพลาดเซิร์ฟเวอร์ กำลังลองใหม่ในภายหลัง", + "MODULE_ERROR_CLIENT_ERROR": "คำขอล้มเหลว", "MODULE_ERROR_UNSPECIFIED": "ตรวจสอบบันทึกสำหรับรายละเอียดเพิ่มเติม", "NEWSFEED_NO_ITEMS": "ไม่มีข่าวในขณะนี้", + "NEWSFEED_ARTICLE_UNAVAILABLE": "ไม่สามารถแสดงบทความที่นี่ได้", "UPDATE_NOTIFICATION": "MagicMirror² มีการอัปเดต", "UPDATE_NOTIFICATION_MODULE": "มีการอัปเดตสำหรับโมดูล {MODULE_NAME}", diff --git a/translations/tlh.json b/translations/tlh.json index 05d24c5b1f..295d40d411 100644 --- a/translations/tlh.json +++ b/translations/tlh.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL ghobe' yImev.", "MODULE_ERROR_NO_CONNECTION": "Internet ghobe' yImev.", "MODULE_ERROR_UNAUTHORIZED": "ghobe' yImev.", + "MODULE_ERROR_RATE_LIMITED": "tlhoy rurqu' lut. vaj ratlh.", + "MODULE_ERROR_SERVER_ERROR": "Qagh server. vaj ratlh.", + "MODULE_ERROR_CLIENT_ERROR": "lut Qagh.", "MODULE_ERROR_UNSPECIFIED": "logmeyDaq yImev.", "NEWSFEED_NO_ITEMS": "DaHghachmey ghobe' yImev.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "QIngheb cha' tu'be'lu'.", "UPDATE_NOTIFICATION": " De'chu' MagicMirror² lI'laH.", "UPDATE_NOTIFICATION_MODULE": "bobcho' {MODULE_NAME} lI'laH De'chu.", diff --git a/translations/tr.json b/translations/tr.json index 08f0690401..6688addabe 100644 --- a/translations/tr.json +++ b/translations/tr.json @@ -35,9 +35,13 @@ "MODULE_ERROR_MALFORMED_URL": "Hatalı URL.", "MODULE_ERROR_NO_CONNECTION": "İnternet bağlantısı yok.", "MODULE_ERROR_UNAUTHORIZED": "Yetkilendirme başarısız.", + "MODULE_ERROR_RATE_LIMITED": "Çok fazla istek. Daha sonra yeniden deneniyor.", + "MODULE_ERROR_SERVER_ERROR": "Sunucu hatası. Daha sonra yeniden deneniyor.", + "MODULE_ERROR_CLIENT_ERROR": "İstek başarısız oldu.", "MODULE_ERROR_UNSPECIFIED": "Daha fazla ayrıntı için günlükleri kontrol edin.", "NEWSFEED_NO_ITEMS": "Şu anda haber yok.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Makale burada görüntülenemiyor.", "UPDATE_NOTIFICATION": "MagicMirror² güncellemesi mevcut.", "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} modulü için güncelleme mevcut.", diff --git a/translations/uk.json b/translations/uk.json index 70f5a3cbac..1aad1ba6f9 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "Неправильний URL.", "MODULE_ERROR_NO_CONNECTION": "Немає підключення до Інтернету.", "MODULE_ERROR_UNAUTHORIZED": "Авторизація не вдалася.", + "MODULE_ERROR_RATE_LIMITED": "Забагато запитів. Повторна спроба пізніше.", + "MODULE_ERROR_SERVER_ERROR": "Помилка сервера. Повторна спроба пізніше.", + "MODULE_ERROR_CLIENT_ERROR": "Запит не вдався.", "MODULE_ERROR_UNSPECIFIED": "Перевірте журнали для отримання додаткової інформації.", "NEWSFEED_NO_ITEMS": "Немає новин на даний момент.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Статтю неможливо відобразити тут.", "UPDATE_NOTIFICATION": "Є оновлення для MagicMirror².", "UPDATE_NOTIFICATION_MODULE": "Є оновлення для модуля {MODULE_NAME}.", diff --git a/translations/zh-cn.json b/translations/zh-cn.json index 7ece692365..146a8a0881 100644 --- a/translations/zh-cn.json +++ b/translations/zh-cn.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "URL格式错误。", "MODULE_ERROR_NO_CONNECTION": "无网络连接。", "MODULE_ERROR_UNAUTHORIZED": "授权失败。", + "MODULE_ERROR_RATE_LIMITED": "请求过多。稍后重试。", + "MODULE_ERROR_SERVER_ERROR": "服务器错误。稍后重试。", + "MODULE_ERROR_CLIENT_ERROR": "请求失败。", "MODULE_ERROR_UNSPECIFIED": "请查看日志以获取更多详细信息。", "NEWSFEED_NO_ITEMS": "目前没有新闻。", + "NEWSFEED_ARTICLE_UNAVAILABLE": "文章无法在此处显示。", "UPDATE_NOTIFICATION": "MagicMirror²有新的版本。", "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME}模块可更新。", diff --git a/translations/zh-tw.json b/translations/zh-tw.json index 8b6a9dac38..1c21632bba 100644 --- a/translations/zh-tw.json +++ b/translations/zh-tw.json @@ -36,9 +36,13 @@ "MODULE_ERROR_MALFORMED_URL": "網址格式錯誤。", "MODULE_ERROR_NO_CONNECTION": "無網路連線。", "MODULE_ERROR_UNAUTHORIZED": "授權失敗。", + "MODULE_ERROR_RATE_LIMITED": "請求過多。稍後重試。", + "MODULE_ERROR_SERVER_ERROR": "伺服器錯誤。稍後重試。", + "MODULE_ERROR_CLIENT_ERROR": "請求失敗。", "MODULE_ERROR_UNSPECIFIED": "查看日誌以了解詳情。", "NEWSFEED_NO_ITEMS": "目前沒有新聞。", + "NEWSFEED_ARTICLE_UNAVAILABLE": "文章無法在此處顯示。", "UPDATE_NOTIFICATION": "MagicMirror² 有可用更新。", "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} 模組有可用更新。", diff --git a/vitest.config.mjs b/vitest.config.mjs index d8957c739e..80c231face 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -21,6 +21,8 @@ export default defineConfig({ setupFiles: ["./tests/utils/vitest-setup.js"], // Stop test execution on first failure bail: 3, + // Automatically restore all mocks after each test to prevent leaks + restoreAllMocks: true, // Shared exclude patterns exclude: [ @@ -45,7 +47,7 @@ export default defineConfig({ setupFiles: ["./tests/utils/vitest-setup.js"], include: [ "tests/unit/**/*_spec.js", - "tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js" + "tests/unit/defaultmodules/calendar/calendar_fetcher_utils_bad_rrule.js" ], testTimeout: 20000, hookTimeout: 10000 @@ -82,7 +84,7 @@ export default defineConfig({ include: [ "clientonly/**/*.js", "js/**/*.js", - "modules/default/**/*.js", + "defaultmodules/**/*.js", "serveronly/**/*.js" ], exclude: [