Release 2.33.0 (#3903)

This commit is contained in:
Kristjan ESPERANTO
2025-09-30 18:02:22 +02:00
committed by GitHub
parent 62b0f7f26e
commit b0c5924019
77 changed files with 2811 additions and 2654 deletions

View File

@@ -18,9 +18,9 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: lts/* node-version: lts/*
cache: "npm" cache: "npm"
@@ -38,16 +38,16 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
strategy: strategy:
matrix: matrix:
node-version: [22.14.0, 22.x, 24.x] node-version: [22.18.0, 22.x, 24.x]
steps: steps:
- name: Install electron dependencies and labwc - name: Install electron dependencies and labwc
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libnss3 libasound2t64 labwc sudo apt-get install -y libnss3 libasound2t64 labwc
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: "Use Node.js ${{ matrix.node-version }}" - name: "Use Node.js ${{ matrix.node-version }}"
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
check-latest: true check-latest: true

View File

@@ -13,6 +13,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: "Dependency Review" - name: "Dependency Review"
uses: actions/dependency-review-action@v4 uses: actions/dependency-review-action@v4

View File

@@ -8,12 +8,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [22.14.0, 22.x, 24.x] node-version: [22.18.0, 22.x, 24.x]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: "Use Node.js ${{ matrix.node-version }}" - name: "Use Node.js ${{ matrix.node-version }}"
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
check-latest: true check-latest: true

View File

@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
ref: develop ref: develop
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: lts/* node-version: lts/*
check-latest: true check-latest: true

View File

@@ -12,7 +12,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v10
with: with:
stale-issue-message: "This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions." stale-issue-message: "This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions."
days-before-issue-stale: 60 days-before-issue-stale: 60

View File

@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/#donate) With your help we can continue to improve the MagicMirror². ❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/#donate) With your help we can continue to improve the MagicMirror².
## [2.33.0] - 2025-10-01
Thanks to: @Crazylegstoo, @dathbe, @m-idler, @plebcity, @khassel, @KristjanESPERANTO, @rejas and @sdetweil!
> ⚠️ This release needs nodejs version `v22.18.0 or higher`
### Added
- Add configuration option for `User-Agent`, used by calendar & news module (#3255)
- [linter] Add prettier plugin for nunjuck templates (#3887)
- [core] Add clear log for occupied port at startup (#3890)
### Changed
- [clock] Add CSS to prevent line breaking of sunset/sunrise time display (#3816)
- [core] Enhance system information logging format and include additional env and RAM details (#3839, #3843)
- [refactor] Add new file `js/module_functions.js` to move code used in several modules to one place (#3837)
- [refactor] Use global.root_path where possible and add tests for config:check (#3883, #3885, #3886, #3889)
- [tests] refactor: simplify jest config file (#3844)
- [tests] refactor: extract constants for weather electron tests (#3845)
- [tests] refactor: add `setupDOMEnvironment` helper function to eliminate repetitive JSDOM setup code (#3860)
- [tests] replace `console` with `Log` in calendar `debug.js` to avoid exception in eslint config (#3846)
- [tests] speed up e2e tests, cleanup and stabilize weather e2e tests, update snapshot url (#3847, #3848, #3861)
- [tests] refactor translation tests (#3866)
- Remove `sinon` dependency in favor of Jest native mocking
- Unify test helper functions across translation test suites
- Rename `setupDOMEnvironment` to `createTranslationTestEnvironment` for consistency
- Simplify DOM setup by removing unnecessary Promise/async patterns
- Avoid potential port conflicts by using port 3001 for translator unit tests
- Improve test reliability and maintainability
- [tests] add alert module tests for different welcome_message configurations (#3867)
- [lint-staged] use `prettier --write --ignore-unknown` in `lint-staged` to avoid errors on unsupported files (#3888)
### Updated
- [calendar] Update defaultSymbol name and also the link to the icon search site (#3879)
- [core] Update dependencies including electron to v38 as well as github actions (#3831, #3849, #3857, #3858, #3872, #3876, #3882, #3891, #3896)
- [weather] Update feels_like temperature calculation formula (#3869)
- [weather] Update null value handling for weather type (#3892)
- [layout] Update styles for weather and calendar (#3894)
### Fixed
- [calendar] Fixed broken unittest that only broke on the 1st of July and 1st of january (#3830)
- [clock] Fixed missing icons when no other modules with icons is loaded (#3834)
- [weather] Fixed handling of empty values in weathergov providers handling of precipitationAmount (#3859)
- [calendar] Fix regression handling of limit days (#3840)
- [calendar] Fixed regression of calendarfetcherutils.shouldEventBeExcluded (#3841)
- [core] Fixed socket.io timeout when server is slow to send notification, notification lost at client (#3380)
- [tests] refactor AnimateCSS tests after jsdom 27 upgrade (#3891)
- [weather] Use `apparent_temperature` data from openmeteo's hourly weather for current feelsLikeTemp (#3868).
- [weather] Updated envcanada Provider to use new database/URL schema for accessing weather data (#3878).
## [2.32.0] - 2025-07-01 ## [2.32.0] - 2025-07-01
Thanks to: @bughaver, @bugsounet, @khassel, @KristjanESPERANTO, @plebcity, @rejas, @sdetweil. Thanks to: @bughaver, @bugsounet, @khassel, @KristjanESPERANTO, @plebcity, @rejas, @sdetweil.
@@ -260,7 +313,7 @@ For more info, please read the following post: [A New Chapter for MagicMirror: T
### Added ### Added
- Output of system information to the console for troubleshooting (#3328 and #3337), ignore errors under aarch64 (#3349) - Output of system information to the console for troubleshooting (#3328 and #3337), ignore errors under aarch64 (#3349)
- [core] Add `eslint-plugin-package-json` to lint the `package.json` files (#3368) - [linter] Add `eslint-plugin-package-json` to lint the `package.json` files (#3368)
- [weather] `showHumidity` config is now a string describing where to show this element. Supported values: "wind", "temp", "feelslike", "below", "none". (#3330) - [weather] `showHumidity` config is now a string describing where to show this element. Supported values: "wind", "temp", "feelslike", "below", "none". (#3330)
- electron-rebuild test suite for electron and 3rd party modules compatibility (#3392) - electron-rebuild test suite for electron and 3rd party modules compatibility (#3392)
- Create MM² icon and attach it to electron process (#3407) - Create MM² icon and attach it to electron process (#3407)
@@ -277,7 +330,7 @@ For more info, please read the following post: [A New Chapter for MagicMirror: T
- Update translations for estonian (#3371) - Update translations for estonian (#3371)
- Update electron to v29 and update other dependencies - Update electron to v29 and update other dependencies
- [calendar] fullDay events over several days now show the left days from the first day on and 'today' on the last day - [calendar] fullDay events over several days now show the left days from the first day on and 'today' on the last day
- Update layout of current weather indoor values - [weather] Update layout of current weather indoor values
### Fixed ### Fixed
@@ -422,7 +475,7 @@ Special thanks to @khassel, @rejas and @sdetweil for taking over most (if not al
- Added UV Index to hourly and current Weather, with support for Openmeteo - Added UV Index to hourly and current Weather, with support for Openmeteo
- Added tests for serveronly - Added tests for serveronly
- Set Timezone `Europe/Berlin` in unit tests (needed for new formatTime tests) - Set Timezone `Europe/Berlin` in unit tests (needed for new formatTime tests)
- Added no-param-reassign eslint rule and fix warnings - [linter] Added no-param-reassign eslint rule and fix warnings
- [updatenotification] Added `sendUpdatesNotifications` feature. Broadcast update with `UPDATES` notification to other modules - [updatenotification] Added `sendUpdatesNotifications` feature. Broadcast update with `UPDATES` notification to other modules
- [updatenotification] Allow force scanning with `SCAN_UPDATES` notification from other modules - [updatenotification] Allow force scanning with `SCAN_UPDATES` notification from other modules
- Added per-calendar fetchInterval - Added per-calendar fetchInterval
@@ -687,7 +740,7 @@ Special thanks to the following contributors: @AmpioRosso, @eouia, @fewieden, @j
### Fixed ### Fixed
- Fixed wrong file `kr.json` to `ko.json`. Use language code 'ko' instead of 'kr' for Korean language. - Fixed wrong file `kr.json` to `ko.json`. Use language code 'ko' instead of 'kr' for Korean language.
- Fixed `feels_like` data from openweathermap's current weather being ignored (#2678). - [weather] Fixed `feels_like` data from openweathermap's current weather being ignored (#2678).
- Fixed chaotic newsfeed display after network connection loss thanks to @jalibu (#2638). - Fixed chaotic newsfeed display after network connection loss thanks to @jalibu (#2638).
- Fixed incorrect time zone correction of recurring full day events (#2632 and #2634). - Fixed incorrect time zone correction of recurring full day events (#2632 and #2634).
- Fixed e2e tests by increasing testTimeout. - Fixed e2e tests by increasing testTimeout.
@@ -725,7 +778,7 @@ Special thanks to the following contributors: @apiontek, @eouia, @jupadin, @khas
- Actually test all js and css files when lint script is run. - Actually test all js and css files when lint script is run.
- Updated jsdocs and print warnings during testing too. - Updated jsdocs and print warnings during testing too.
- Updated weathergov provider to try fetching not just current, but also forecast, when API URLs available. - Updated weathergov provider to try fetching not just current, but also forecast, when API URLs available.
- Refactored clock layout. - [clock] Refactored clock layout.
- Refactored methods from weather-providers into weatherobject (isDaytime, updateSunTime). - Refactored methods from weather-providers into weatherobject (isDaytime, updateSunTime).
- Use of `logger.js` in jest tests. - Use of `logger.js` in jest tests.
- Run prettier over all relevant files. - Run prettier over all relevant files.
@@ -1771,6 +1824,7 @@ It includes (but is not limited to) the following features:
This was part of the blogpost: [https://michaelteeuw.nl/post/83916869600/magic-mirror-part-vi-production-of-the](https://michaelteeuw.nl/post/83916869600/magic-mirror-part-vi-production-of-the) This was part of the blogpost: [https://michaelteeuw.nl/post/83916869600/magic-mirror-part-vi-production-of-the](https://michaelteeuw.nl/post/83916869600/magic-mirror-part-vi-production-of-the)
[2.33.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.32.0...v2.33.0
[2.32.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.31.0...v2.32.0 [2.32.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.31.0...v2.32.0
[2.31.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.30.0...v2.31.0 [2.31.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.30.0...v2.31.0
[2.30.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.29.0...v2.30.0 [2.30.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.29.0...v2.30.0

View File

@@ -35,7 +35,7 @@ Are done by
- [ ] test `prep-release` branch - [ ] test `prep-release` branch
- [ ] update `CHANGELOG.md` - [ ] update `CHANGELOG.md`
- [ ] add all contributor names: `...` - [ ] add all contributor names: `...`
- [ ] add min. node version: > ⚠️ This release needs nodejs version `v22.14.0` or higher - [ ] add min. node version: > ⚠️ This release needs nodejs version `v22.18.0` or higher
- [ ] check release link at the bottom of the file - [ ] check release link at the bottom of the file
- [ ] commit and push all changes - [ ] commit and push all changes
- [ ] create pull request from `prep-release` to `develop` branch with title `Prepare Release 2.xx.0` - [ ] create pull request from `prep-release` to `develop` branch with title `Prepare Release 2.xx.0`

View File

@@ -21,6 +21,7 @@
"browserwindow", "browserwindow",
"bryanzzhu", "bryanzzhu",
"btoconnor", "btoconnor",
"bughaver",
"bugsounet", "bugsounet",
"buxxi", "buxxi",
"byday", "byday",
@@ -44,6 +45,7 @@
"darksky", "darksky",
"dateheader", "dateheader",
"dateheaders", "dateheaders",
"dathbe",
"davide", "davide",
"DAYAFTERTOMORROW", "DAYAFTERTOMORROW",
"DAYBEFOREYESTERDAY", "DAYBEFOREYESTERDAY",
@@ -175,6 +177,7 @@
"oraclesean", "oraclesean",
"oscarb", "oscarb",
"philnagel", "philnagel",
"plebcity",
"Português", "Português",
"PRECIP", "PRECIP",
"Problema", "Problema",
@@ -233,6 +236,7 @@
"Weatherflow", "Weatherflow",
"weatherforecast", "weatherforecast",
"weathergov", "weathergov",
"weathericon",
"weathericons", "weathericons",
"weatherobject", "weatherobject",
"weatherutils", "weatherutils",

View File

@@ -252,3 +252,15 @@ sup {
.region .container.hidden { .region .container.hidden {
display: none; display: none;
} }
.region.left .flex {
justify-content: flex-start;
}
.region.center .flex {
justify-content: center;
}
.region.right .flex {
justify-content: flex-end;
}

View File

@@ -3,7 +3,7 @@ import globals from "globals";
import {flatConfigs as importX} from "eslint-plugin-import-x"; import {flatConfigs as importX} from "eslint-plugin-import-x";
import jest from "eslint-plugin-jest"; import jest from "eslint-plugin-jest";
import js from "@eslint/js"; import js from "@eslint/js";
import jsdoc from "eslint-plugin-jsdoc"; import jsdocPlugin from "eslint-plugin-jsdoc";
import packageJson from "eslint-plugin-package-json"; import packageJson from "eslint-plugin-package-json";
import stylistic from "@stylistic/eslint-plugin"; import stylistic from "@stylistic/eslint-plugin";
@@ -23,8 +23,8 @@ export default defineConfig([
moment: "readonly" moment: "readonly"
} }
}, },
plugins: {js, jsdoc, stylistic}, plugins: {js, stylistic},
extends: [importX.recommended, jest.configs["flat/recommended"], "js/recommended", jsdoc.configs["flat/recommended"], "stylistic/all"], extends: [importX.recommended, jest.configs["flat/recommended"], "js/recommended", jsdocPlugin.configs["flat/recommended"], "stylistic/all"],
rules: { rules: {
"@stylistic/array-element-newline": ["error", "consistent"], "@stylistic/array-element-newline": ["error", "consistent"],
"@stylistic/arrow-parens": ["error", "always"], "@stylistic/arrow-parens": ["error", "always"],
@@ -88,7 +88,6 @@ export default defineConfig([
files: ["**/*.js"], files: ["**/*.js"],
ignores: [ ignores: [
"clientonly/index.js", "clientonly/index.js",
"modules/default/calendar/debug.js",
"js/logger.js", "js/logger.js",
"tests/**/*.js" "tests/**/*.js"
], ],

View File

@@ -1,32 +1,37 @@
module.exports = async () => { const config = {
return { verbose: true,
verbose: true, testTimeout: 20000,
testTimeout: 20000, testSequencer: "<rootDir>/tests/utils/test_sequencer.js",
testSequencer: "<rootDir>/tests/utils/test_sequencer.js", projects: [
projects: [ {
{ displayName: "unit",
displayName: "unit", globalSetup: "<rootDir>/tests/unit/helpers/global-setup.js",
globalSetup: "<rootDir>/tests/unit/helpers/global-setup.js", moduleNameMapper: {
moduleNameMapper: { logger: "<rootDir>/js/logger.js"
logger: "<rootDir>/js/logger.js"
},
testMatch: ["**/tests/unit/**/*.[jt]s?(x)"],
testPathIgnorePatterns: ["<rootDir>/tests/unit/mocks", "<rootDir>/tests/unit/helpers"]
}, },
{ testMatch: ["**/tests/unit/**/*.[jt]s?(x)"],
displayName: "electron", testPathIgnorePatterns: ["<rootDir>/tests/unit/mocks", "<rootDir>/tests/unit/helpers"]
testMatch: ["**/tests/electron/**/*.[jt]s?(x)"], },
testPathIgnorePatterns: ["<rootDir>/tests/electron/helpers"] {
}, displayName: "electron",
{ testMatch: ["**/tests/electron/**/*.[jt]s?(x)"],
displayName: "e2e", testPathIgnorePatterns: ["<rootDir>/tests/electron/helpers"]
testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"], },
modulePaths: ["<rootDir>/js/"], {
testPathIgnorePatterns: ["<rootDir>/tests/e2e/helpers", "<rootDir>/tests/e2e/mocks"] displayName: "e2e",
} testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"],
], modulePaths: ["<rootDir>/js/"],
collectCoverageFrom: ["./clientonly/**/*.js", "./js/**/*.js", "./modules/default/**/*.js", "./serveronly/**/*.js"], testPathIgnorePatterns: ["<rootDir>/tests/e2e/helpers", "<rootDir>/tests/e2e/mocks"]
coverageReporters: ["lcov", "text"], }
coverageProvider: "v8" ],
}; collectCoverageFrom: [
"<rootDir>/clientonly/**/*.js",
"<rootDir>/js/**/*.js",
"<rootDir>/modules/default/**/*.js",
"<rootDir>/serveronly/**/*.js"
],
coverageReporters: ["lcov", "text"],
coverageProvider: "v8"
}; };
module.exports = config;

View File

@@ -6,26 +6,26 @@ const path = require("node:path");
const envsub = require("envsub"); const envsub = require("envsub");
const Log = require("logger"); const Log = require("logger");
// global absolute root path
global.root_path = path.resolve(`${__dirname}/../`);
const Server = require(`${__dirname}/server`); const Server = require(`${__dirname}/server`);
const Utils = require(`${__dirname}/utils`); const Utils = require(`${__dirname}/utils`);
const defaultModules = require(`${__dirname}/../modules/default/defaultmodules`);
const { getEnvVarsAsObj } = require(`${__dirname}/server_functions`);
const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);
// used to control fetch timeout for node_helpers // used to control fetch timeout for node_helpers
const { setGlobalDispatcher, Agent } = require("undici"); const { setGlobalDispatcher, Agent } = require("undici");
const { getEnvVarsAsObj } = require("#server_functions");
// common timeout value, provide environment override in case // common timeout value, provide environment override in case
const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000; const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;
// Get version number. // Get version number.
global.version = require(`${__dirname}/../package.json`).version; global.version = require(`${global.root_path}/package.json`).version;
global.mmTestMode = process.env.mmTestMode === "true"; global.mmTestMode = process.env.mmTestMode === "true";
Log.log(`Starting MagicMirror: v${global.version}`); Log.log(`Starting MagicMirror: v${global.version}`);
// Log system information. // Log system information.
Utils.logSystemInformation(); Utils.logSystemInformation(global.version);
// global absolute root path
global.root_path = path.resolve(`${__dirname}/../`);
if (process.env.MM_CONFIG_FILE) { if (process.env.MM_CONFIG_FILE) {
global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, ""); global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, "");
@@ -181,10 +181,10 @@ function App () {
const elements = module.split("/"); const elements = module.split("/");
const moduleName = elements[elements.length - 1]; const moduleName = elements[elements.length - 1];
const env = getEnvVarsAsObj(); const env = getEnvVarsAsObj();
let moduleFolder = path.resolve(`${__dirname}/../${env.modulesDir}`, module); let moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module);
if (defaultModules.includes(moduleName)) { if (defaultModules.includes(moduleName)) {
const defaultModuleFolder = path.resolve(`${__dirname}/../modules/default/`, module); const defaultModuleFolder = path.resolve(`${global.root_path}/modules/default/`, module);
if (process.env.JEST_WORKER_ID === undefined) { if (process.env.JEST_WORKER_ID === undefined) {
moduleFolder = defaultModuleFolder; moduleFolder = defaultModuleFolder;
} else { } else {

View File

@@ -90,7 +90,7 @@ const MM = (function () {
/** /**
* Send a notification to all modules. * Send a notification to all modules.
* @param {string} notification The identifier of the notification. * @param {string} notification The identifier of the notification.
* @param {*} payload The payload of the notification. * @param {object} payload The payload of the notification.
* @param {Module} sender The module that sent the notification. * @param {Module} sender The module that sent the notification.
* @param {Module} [sendTo] The (optional) module to send the notification to. * @param {Module} [sendTo] The (optional) module to send the notification to.
*/ */
@@ -262,7 +262,7 @@ const MM = (function () {
* Hide the module. * Hide the module.
* @param {Module} module The module to hide. * @param {Module} module The module to hide.
* @param {number} speed The speed of the hide animation. * @param {number} speed The speed of the hide animation.
* @param {Function} callback Called when the animation is done. * @param {Promise} callback Called when the animation is done.
* @param {object} [options] Optional settings for the hide method. * @param {object} [options] Optional settings for the hide method.
*/ */
const hideModule = function (module, speed, callback, options = {}) { const hideModule = function (module, speed, callback, options = {}) {
@@ -347,7 +347,7 @@ const MM = (function () {
* Show the module. * Show the module.
* @param {Module} module The module to show. * @param {Module} module The module to show.
* @param {number} speed The speed of the show animation. * @param {number} speed The speed of the show animation.
* @param {Function} callback Called when the animation is done. * @param {Promise} callback Called when the animation is done.
* @param {object} [options] Optional settings for the show method. * @param {object} [options] Optional settings for the show method.
*/ */
const showModule = function (module, speed, callback, options = {}) { const showModule = function (module, speed, callback, options = {}) {
@@ -552,7 +552,7 @@ const MM = (function () {
/** /**
* Walks thru a collection of modules and executes the callback with the module as an argument. * Walks thru a collection of modules and executes the callback with the module as an argument.
* @param {Function} callback The function to execute with the module as an argument. * @param {module} callback The function to execute with the module as an argument.
*/ */
const enumerate = function (callback) { const enumerate = function (callback) {
modules.map(function (module) { modules.map(function (module) {
@@ -629,7 +629,7 @@ const MM = (function () {
/** /**
* Send a notification to all modules. * Send a notification to all modules.
* @param {string} notification The identifier of the notification. * @param {string} notification The identifier of the notification.
* @param {*} payload The payload of the notification. * @param {object} payload The payload of the notification.
* @param {Module} sender The module that sent the notification. * @param {Module} sender The module that sent the notification.
*/ */
sendNotification (notification, payload, sender) { sendNotification (notification, payload, sender) {
@@ -688,7 +688,7 @@ const MM = (function () {
* Hide the module. * Hide the module.
* @param {Module} module The module to hide. * @param {Module} module The module to hide.
* @param {number} speed The speed of the hide animation. * @param {number} speed The speed of the hide animation.
* @param {Function} callback Called when the animation is done. * @param {Promise} callback Called when the animation is done.
* @param {object} [options] Optional settings for the hide method. * @param {object} [options] Optional settings for the hide method.
*/ */
hideModule (module, speed, callback, options) { hideModule (module, speed, callback, options) {
@@ -700,7 +700,7 @@ const MM = (function () {
* Show the module. * Show the module.
* @param {Module} module The module to show. * @param {Module} module The module to show.
* @param {number} speed The speed of the show animation. * @param {number} speed The speed of the show animation.
* @param {Function} callback Called when the animation is done. * @param {Promise} callback Called when the animation is done.
* @param {object} [options] Optional settings for the show method. * @param {object} [options] Optional settings for the show method.
*/ */
showModule (module, speed, callback, options) { showModule (module, speed, callback, options) {

View File

@@ -68,7 +68,7 @@ const Module = Class.extend({
* Returns a map of translation files the module requires to be loaded. * Returns a map of translation files the module requires to be loaded.
* *
* return Map<String, String> - * return Map<String, String> -
* @returns {*} A map with langKeys and filenames. * @returns {Map} A map with langKeys and filenames.
*/ */
getTranslations () { getTranslations () {
return false; return false;
@@ -140,7 +140,7 @@ const Module = Class.extend({
/** /**
* Called by the MagicMirror² core when a notification arrives. * Called by the MagicMirror² core when a notification arrives.
* @param {string} notification The identifier of the notification. * @param {string} notification The identifier of the notification.
* @param {*} payload The payload of the notification. * @param {object} payload The payload of the notification.
* @param {Module} sender The module that sent the notification. * @param {Module} sender The module that sent the notification.
*/ */
notificationReceived (notification, payload, sender) { notificationReceived (notification, payload, sender) {
@@ -176,7 +176,7 @@ const Module = Class.extend({
/** /**
* Called when a socket notification arrives. * Called when a socket notification arrives.
* @param {string} notification The identifier of the notification. * @param {string} notification The identifier of the notification.
* @param {*} payload The payload of the notification. * @param {object} payload The payload of the notification.
*/ */
socketNotificationReceived (notification, payload) { socketNotificationReceived (notification, payload) {
Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`); Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);
@@ -344,7 +344,7 @@ const Module = Class.extend({
/** /**
* Send a notification to all modules. * Send a notification to all modules.
* @param {string} notification The identifier of the notification. * @param {string} notification The identifier of the notification.
* @param {*} payload The payload of the notification. * @param {object} payload The payload of the notification.
*/ */
sendNotification (notification, payload) { sendNotification (notification, payload) {
MM.sendNotification(notification, payload, this); MM.sendNotification(notification, payload, this);
@@ -353,7 +353,7 @@ const Module = Class.extend({
/** /**
* Send a socket notification to the node helper. * Send a socket notification to the node helper.
* @param {string} notification The identifier of the notification. * @param {string} notification The identifier of the notification.
* @param {*} payload The payload of the notification. * @param {object} payload The payload of the notification.
*/ */
sendSocketNotification (notification, payload) { sendSocketNotification (notification, payload) {
this.socket().sendNotification(notification, payload); this.socket().sendNotification(notification, payload);
@@ -362,7 +362,7 @@ const Module = Class.extend({
/** /**
* Hide this module. * Hide this module.
* @param {number} speed The speed of the hide animation. * @param {number} speed The speed of the hide animation.
* @param {Function} callback Called when the animation is done. * @param {Promise} callback Called when the animation is done.
* @param {object} [options] Optional settings for the hide method. * @param {object} [options] Optional settings for the hide method.
*/ */
hide (speed, callback, options = {}) { hide (speed, callback, options = {}) {
@@ -389,7 +389,7 @@ const Module = Class.extend({
/** /**
* Show this module. * Show this module.
* @param {number} speed The speed of the show animation. * @param {number} speed The speed of the show animation.
* @param {Function} callback Called when the animation is done. * @param {Promise} callback Called when the animation is done.
* @param {object} [options] Optional settings for the show method. * @param {object} [options] Optional settings for the show method.
*/ */
show (speed, callback, options) { show (speed, callback, options) {

18
js/module_functions.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* Schedule the timer for the next update
* @param {object} timer The timer of the module
* @param {bigint} intervalMS interval in milliseconds
* @param {Promise} callback function to call when the timer expires
*/
const scheduleTimer = function (timer, intervalMS, callback) {
if (process.env.JEST_WORKER_ID === undefined) {
// only set timer when not running in jest
let tmr = timer;
clearTimeout(tmr);
tmr = setTimeout(function () {
callback();
}, intervalMS);
}
};
module.exports = { scheduleTimer };

View File

@@ -27,7 +27,7 @@ const NodeHelper = Class.extend({
/** /**
* This method is called when a socket notification arrives. * This method is called when a socket notification arrives.
* @param {string} notification The identifier of the notification. * @param {string} notification The identifier of the notification.
* @param {*} payload The payload of the notification. * @param {object} payload The payload of the notification.
*/ */
socketNotificationReceived (notification, payload) { socketNotificationReceived (notification, payload) {
Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`); Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);

View File

@@ -7,7 +7,7 @@ const ipfilter = require("express-ipfilter").IpFilter;
const helmet = require("helmet"); const helmet = require("helmet");
const socketio = require("socket.io"); const socketio = require("socket.io");
const Log = require("logger"); const Log = require("logger");
const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("./server_functions"); const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions");
const vendor = require(`${__dirname}/vendor`); const vendor = require(`${__dirname}/vendor`);
@@ -42,7 +42,9 @@ function Server (config) {
origin: /.*$/, origin: /.*$/,
credentials: true credentials: true
}, },
allowEIO3: true allowEIO3: true,
pingInterval: 120000, // server → client ping every 2 mins
pingTimeout: 120000 // wait up to 2 mins for client pong
}); });
server.on("connection", (socket) => { server.on("connection", (socket) => {
@@ -53,6 +55,29 @@ function Server (config) {
}); });
Log.log(`Starting server on port ${port} ... `); Log.log(`Starting server on port ${port} ... `);
// Add explicit error handling BEFORE calling listen so we can give user-friendly feedback
server.once("error", (err) => {
if (err && err.code === "EADDRINUSE") {
const bindAddr = config.address || "localhost";
const portInUseMessage = [
"",
"────────────────────────────────────────────────────────────────",
` PORT IN USE: ${bindAddr}:${port}`,
"",
" Another process (most likely another MagicMirror instance)",
" is already using this port.",
"",
" Stop the other process (free the port) or use a different port.",
"────────────────────────────────────────────────────────────────"
].join("\n");
Log.error(portInUseMessage);
return;
}
Log.error("Failed to start server:", err);
});
server.listen(port, config.address || "localhost"); server.listen(port, config.address || "localhost");
if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) { if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) {

View File

@@ -69,7 +69,7 @@ async function cors (req, res) {
* @returns {object} An object specifying name and value of the headers. * @returns {object} An object specifying name and value of the headers.
*/ */
function getHeadersToSend (url) { function getHeadersToSend (url) {
const headersToSend = { "User-Agent": `Mozilla/5.0 MagicMirror/${global.version}` }; const headersToSend = { "User-Agent": getUserAgent() };
const headersToSendMatch = new RegExp("sendheaders=(.+?)(&|$)", "g").exec(url); const headersToSendMatch = new RegExp("sendheaders=(.+?)(&|$)", "g").exec(url);
if (headersToSendMatch) { if (headersToSendMatch) {
const headers = headersToSendMatch[1].split(","); const headers = headersToSendMatch[1].split(",");
@@ -129,6 +129,27 @@ function getVersion (req, res) {
res.send(global.version); res.send(global.version);
} }
/**
* Gets the preferred `User-Agent`
* @returns {string} `User-Agent` to be used
*/
function getUserAgent () {
const defaultUserAgent = `Mozilla/5.0 (Node.js ${Number(process.version.match(/^v(\d+\.\d+)/)[1])}) MagicMirror/${global.version}`;
if (typeof config === "undefined") {
return defaultUserAgent;
}
switch (typeof config.userAgent) {
case "function":
return config.userAgent();
case "string":
return config.userAgent;
default:
return defaultUserAgent;
}
}
/** /**
* Gets environment variables needed in the browser. * Gets environment variables needed in the browser.
* @returns {object} environment variables key: values * @returns {object} environment variables key: values
@@ -155,4 +176,4 @@ function getEnvVars (req, res) {
res.send(obj); res.send(obj);
} }
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj }; module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent };

View File

@@ -13,7 +13,9 @@ const MMSocket = function (moduleName) {
base = config.basePath; base = config.basePath;
} }
this.socket = io(`/${this.moduleName}`, { this.socket = io(`/${this.moduleName}`, {
path: `${base}socket.io` path: `${base}socket.io`,
pingInterval: 120000, // send pings every 2 mins
pingTimeout: 120000 // wait up to 2 mins for a pong
}); });
let notificationCallback = function () {}; let notificationCallback = function () {};

View File

@@ -1,4 +1,3 @@
const execSync = require("node:child_process").execSync;
const path = require("node:path"); const path = require("node:path");
const rootPath = path.resolve(`${__dirname}/../`); const rootPath = path.resolve(`${__dirname}/../`);
@@ -14,27 +13,34 @@ const discoveredPositionsJSFilename = "js/positions.js";
module.exports = { module.exports = {
async logSystemInformation () { async logSystemInformation (mirrorVersion) {
try { try {
let installedNodeVersion = execSync("node -v", { encoding: "utf-8" }).replace("v", "").replace(/(?:\r\n|\r|\n)/g, ""); const system = await si.system();
const osInfo = await si.osInfo();
const versions = await si.versions();
const staticData = await si.get({ const usedNodeVersion = process.version.replace("v", "");
system: "manufacturer, model, virtual", const installedNodeVersion = versions.node;
osInfo: "platform, distro, release, arch", const totalRam = (os.totalmem() / 1024 / 1024).toFixed(2);
versions: "kernel, node, npm, pm2" const freeRam = (os.freemem() / 1024 / 1024).toFixed(2);
}); const usedRam = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(2);
let systemDataString = `System information:
### SYSTEM: manufacturer: ${staticData.system.manufacturer}; model: ${staticData.system.model}; virtual: ${staticData.system.virtual} let systemDataString = [
### OS: platform: ${staticData.osInfo.platform}; distro: ${staticData.osInfo.distro}; release: ${staticData.osInfo.release}; arch: ${staticData.osInfo.arch}; kernel: ${staticData.versions.kernel} "\n#### System Information ####",
### VERSIONS: electron: ${process.versions.electron}; used node: ${staticData.versions.node}; installed node: ${installedNodeVersion}; npm: ${staticData.versions.npm}; pm2: ${staticData.versions.pm2} `- SYSTEM: manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}; MM: ${mirrorVersion}`,
### OTHER: timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}` `- OS: platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`,
.replace(/\t/g, ""); `- 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); Log.info(systemDataString);
// Return is currently only for jest // Return is currently only for jest
return systemDataString; return systemDataString;
} catch (e) { } catch (error) {
Log.error(e); Log.error(error);
} }
}, },

View File

@@ -9,7 +9,7 @@
font-size: 70%; font-size: 70%;
position: relative; position: relative;
display: table; display: table;
word-wrap: break-word; overflow-wrap: break-word;
max-width: 100%; max-width: 100%;
border-width: 1px; border-width: 1px;
border-radius: 5px; border-radius: 5px;
@@ -35,7 +35,7 @@
top: 40%; top: 40%;
width: 40%; width: 40%;
height: auto; height: auto;
word-wrap: break-word; overflow-wrap: break-word;
border-radius: 20px; border-radius: 20px;
} }

View File

@@ -1,20 +1,20 @@
{% if imageUrl or imageFA %} {% if imageUrl or imageFA %}
{% set imageHeight = imageHeight if imageHeight else "80px" %} {% set imageHeight = imageHeight if imageHeight else "80px" %}
{% if imageUrl %} {% if imageUrl %}
<img src="{{ imageUrl }}" <img src="{{ imageUrl }}" height="{{ imageHeight }}" style="margin-bottom: 10px" />
height="{{ imageHeight }}" {% else %}
style="margin-bottom: 10px" /> <span
{% else %} class="bright fas fa-{{ imageFA }}"
<span class="bright fas fa-{{ imageFA }}" style="margin-bottom: 10px;
style="margin-bottom: 10px; font-size: {{ imageHeight }}"
font-size: {{ imageHeight }}"></span> ></span>
{% endif %} {% endif %}
<br /> <br />
{% endif %} {% endif %}
{% if title %} {% if title %}
<span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span> <span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span>
{% endif %} {% endif %}
{% if message %} {% if message %}
{% if title %}<br />{% endif %} {% if title %}<br />{% endif %}
<span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span> <span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span>
{% endif %} {% endif %}

View File

@@ -1,7 +1,7 @@
{% if title %} {% if title %}
<span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span> <span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span>
{% endif %} {% endif %}
{% if message %} {% if message %}
{% if title %}<br />{% endif %} {% if title %}<br />{% endif %}
<span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span> <span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span>
{% endif %} {% endif %}

View File

@@ -2,23 +2,14 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
padding-left: 0; gap: 5px;
padding-right: 10px;
font-size: var(--font-size-small);
}
.calendar .symbol span {
padding-top: 4px;
} }
.calendar .title { .calendar .title {
padding-left: 0; padding: 0 10px;
padding-right: 0;
vertical-align: top;
} }
.calendar .time { .calendar .time {
padding-left: 30px; padding-left: 20px;
text-align: right; text-align: right;
vertical-align: top;
} }

View File

@@ -8,7 +8,7 @@ Module.register("calendar", {
limitDays: 0, // Limit the number of days shown, 0 = no limit limitDays: 0, // Limit the number of days shown, 0 = no limit
pastDaysCount: 0, pastDaysCount: 0,
displaySymbol: true, displaySymbol: true,
defaultSymbol: "calendar-alt", // Fontawesome Symbol see https://fontawesome.com/cheatsheet?from=io defaultSymbol: "calendar-days", // Fontawesome Symbol see https://fontawesome.com/search?ic=free&o=r
defaultSymbolClassName: "fas fa-fw fa-", defaultSymbolClassName: "fas fa-fw fa-",
showLocation: false, showLocation: false,
displayRepeatingCountTitle: false, displayRepeatingCountTitle: false,
@@ -168,8 +168,8 @@ Module.register("calendar", {
this.selfUpdate(); this.selfUpdate();
}, },
notificationReceived (notification, payload, sender) {
notificationReceived (notification, payload, sender) {
if (notification === "FETCH_CALENDAR") { if (notification === "FETCH_CALENDAR") {
if (this.hasCalendarURL(payload.url)) { if (this.hasCalendarURL(payload.url)) {
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier }); this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
@@ -217,7 +217,6 @@ Module.register("calendar", {
// Override dom generator. // Override dom generator.
getDom () { getDom () {
const ONE_SECOND = 1000; // 1,000 milliseconds
const events = this.createEventList(true); const events = this.createEventList(true);
const wrapper = document.createElement("table"); const wrapper = document.createElement("table");
wrapper.className = this.config.tableClass; wrapper.className = this.config.tableClass;
@@ -308,15 +307,12 @@ Module.register("calendar", {
} }
const symbolClass = this.symbolClassForUrl(event.url); const symbolClass = this.symbolClassForUrl(event.url);
symbolWrapper.className = `symbol align-right ${symbolClass}`; symbolWrapper.className = `symbol ${symbolClass}`;
const symbols = this.symbolsForEvent(event); const symbols = this.symbolsForEvent(event);
symbols.forEach((s, index) => { symbols.forEach((s) => {
const symbol = document.createElement("span"); const symbol = document.createElement("span");
symbol.className = s; symbol.className = s;
if (index > 0) {
symbol.style.paddingLeft = "5px";
}
symbolWrapper.appendChild(symbol); symbolWrapper.appendChild(symbol);
}); });
eventWrapper.appendChild(symbolWrapper); eventWrapper.appendChild(symbolWrapper);
@@ -601,7 +597,6 @@ Module.register("calendar", {
*/ */
createEventList (limitNumberOfEntries) { createEventList (limitNumberOfEntries) {
let now = moment(); let now = moment();
let today = now.clone().startOf("day");
let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days"); let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days");
let events = []; let events = [];
@@ -705,30 +700,24 @@ Module.register("calendar", {
* Limit the number of days displayed * Limit the number of days displayed
* If limitDays is set > 0, limit display to that number of days * If limitDays is set > 0, limit display to that number of days
*/ */
if (this.config.limitDays > 0) { if (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper
let newEvents = []; // Group all events by date, events on the same date will be in a list with the key being the date.
let lastDate = today.clone().subtract(1, "days"); const eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format("YYYY-MM-DD"));
let days = 0; const newEvents = [];
for (const ev of events) { let currentDate = moment();
let eventDate = this.timestampToMoment(ev.startDate); let daysCollected = 0;
/* while (daysCollected < this.config.limitDays) {
* if date of event is later than lastdate const dateStr = currentDate.format("YYYY-MM-DD");
* check if we already are showing max unique days // Check if there are events on the currentDate
*/ if (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) {
if (eventDate.isAfter(lastDate)) { // If there are any events today then get all those events and select the currently active events and the events that are starting later in the day.
// if the only entry in the first day is a full day event that day is not counted as unique newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment())));
if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) { // Since we found a day with events, increase the daysCollected by 1
days--; daysCollected++;
}
days++;
if (days > this.config.limitDays) {
continue;
} else {
lastDate = eventDate;
}
} }
newEvents.push(ev); // Search for the next day
currentDate.add(1, "day");
} }
events = newEvents; events = newEvents;
} }
@@ -887,7 +876,7 @@ Module.register("calendar", {
* @param {string} url The calendar url * @param {string} url The calendar url
* @param {string} property The property to look for * @param {string} property The property to look for
* @param {string} defaultValue The value if the property is not found * @param {string} defaultValue The value if the property is not found
* @returns {*} The property * @returns {property} The property
*/ */
getCalendarProperty (url, property, defaultValue) { getCalendarProperty (url, property, defaultValue) {
for (const calendar of this.config.calendars) { for (const calendar of this.config.calendars) {

View File

@@ -3,6 +3,8 @@ const ical = require("node-ical");
const Log = require("logger"); const Log = require("logger");
const NodeHelper = require("node_helper"); const NodeHelper = require("node_helper");
const CalendarFetcherUtils = require("./calendarfetcherutils"); const CalendarFetcherUtils = require("./calendarfetcherutils");
const { getUserAgent } = require("#server_functions");
const { scheduleTimer } = require("#module_functions");
/** /**
* *
@@ -29,10 +31,9 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
const fetchCalendar = () => { const fetchCalendar = () => {
clearTimeout(reloadTimer); clearTimeout(reloadTimer);
reloadTimer = null; reloadTimer = null;
const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
let httpsAgent = null; let httpsAgent = null;
let headers = { let headers = {
"User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}` "User-Agent": getUserAgent()
}; };
if (selfSignedCert) { if (selfSignedCert) {
@@ -65,31 +66,18 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
}); });
} catch (error) { } catch (error) {
fetchFailedCallback(this, error); fetchFailedCallback(this, error);
scheduleTimer(); scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
return; return;
} }
this.broadcastEvents(); this.broadcastEvents();
scheduleTimer(); scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
}) })
.catch((error) => { .catch((error) => {
fetchFailedCallback(this, error); fetchFailedCallback(this, error);
scheduleTimer(); scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
}); });
}; };
/**
* Schedule the timer for the next update.
*/
const scheduleTimer = function () {
if (process.env.JEST_WORKER_ID === undefined) {
// only set timer when not running in jest
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchCalendar();
}, reloadInterval);
}
};
/* public methods */ /* public methods */
/** /**
@@ -109,7 +97,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
/** /**
* Sets the on success callback * Sets the on success callback
* @param {Function} callback The on success callback. * @param {eventsReceivedCallback} callback The on success callback.
*/ */
this.onReceive = function (callback) { this.onReceive = function (callback) {
eventsReceivedCallback = callback; eventsReceivedCallback = callback;
@@ -117,7 +105,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
/** /**
* Sets the on error callback * Sets the on error callback
* @param {Function} callback The on error callback. * @param {fetchFailedCallback} callback The on error callback.
*/ */
this.onError = function (callback) { this.onError = function (callback) {
fetchFailedCallback = callback; fetchFailedCallback = callback;

View File

@@ -3,7 +3,7 @@
*/ */
const moment = require("moment-timezone"); const moment = require("moment-timezone");
const Log = require("../../../js/logger"); const Log = require("logger");
const CalendarFetcherUtils = { const CalendarFetcherUtils = {
@@ -16,7 +16,7 @@ const CalendarFetcherUtils = {
* until: the date until the event should be excluded. * until: the date until the event should be excluded.
*/ */
shouldEventBeExcluded (config, title) { shouldEventBeExcluded (config, title) {
let filter = { let result = {
excluded: false, excluded: false,
until: null until: null
}; };
@@ -55,14 +55,14 @@ const CalendarFetcherUtils = {
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) { if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
if (until) { if (until) {
filter.until = until; result.until = until;
} else { } else {
filter.excluded = true; result.excluded = true;
} }
break; break;
} }
} }
return filter; return result;
}, },
/** /**

View File

@@ -5,6 +5,7 @@
*/ */
// Alias modules mentioned in package.js under _moduleAliases. // Alias modules mentioned in package.js under _moduleAliases.
require("module-alias/register"); require("module-alias/register");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher"); const CalendarFetcher = require("./calendarfetcher");
@@ -20,22 +21,22 @@ const auth = {
pass: pass pass: pass
}; };
console.log("Create fetcher ..."); Log.log("Create fetcher ...");
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth); const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
fetcher.onReceive(function (fetcher) { fetcher.onReceive(function (fetcher) {
console.log(fetcher.events()); Log.log(fetcher.events());
console.log("------------------------------------------------------------"); Log.log("------------------------------------------------------------");
process.exit(0); process.exit(0);
}); });
fetcher.onError(function (fetcher, error) { fetcher.onError(function (fetcher, error) {
console.log("Fetcher error:"); Log.log("Fetcher error:");
console.log(error); Log.log(error);
process.exit(1); process.exit(1);
}); });
fetcher.startFetch(); fetcher.startFetch();
console.log("Create fetcher done! "); Log.log("Create fetcher done! ");

View File

@@ -36,7 +36,7 @@ Module.register("clock", {
}, },
// Define styles. // Define styles.
getStyles () { getStyles () {
return ["clock_styles.css"]; return ["clock_styles.css", "font-awesome.css"];
}, },
// Define start sequence. // Define start sequence.
start () { start () {

View File

@@ -87,9 +87,17 @@
transform-origin: 50% 100%; transform-origin: 50% 100%;
} }
.module.clock .digital {
display: flex;
flex-direction: column;
gap: 3px;
}
.module.clock .sun, .module.clock .sun,
.module.clock .moon { .module.clock .moon {
display: flex; display: flex;
white-space: nowrap;
gap: 10px;
} }
.module.clock .sun > *, .module.clock .sun > *,

View File

@@ -1,3 +1,3 @@
<div> <div>
<iframe class="newsfeed-fullarticle" src="{{ url }}"></iframe> <iframe class="newsfeed-fullarticle" src="{{ url }}"></iframe>
</div> </div>

View File

@@ -181,7 +181,7 @@ Module.register("newsfeed", {
* Gets a feed property by name * Gets a feed property by name
* @param {object} feed A feed object. * @param {object} feed A feed object.
* @param {string} property The name of the property. * @param {string} property The name of the property.
* @returns {*} The value of the specified property for the feed. * @returns {property} The value of the specified property for the feed.
*/ */
getFeedProperty (feed, property) { getFeedProperty (feed, property) {
let res = this.config[property]; let res = this.config[property];

View File

@@ -1,89 +1,89 @@
{% macro escapeText(text, dangerouslyDisableAutoEscaping=false) %} {% macro escapeText(text, dangerouslyDisableAutoEscaping=false) %}
{% if dangerouslyDisableAutoEscaping -%} {% if dangerouslyDisableAutoEscaping -%}
{{ text | safe }} {{ text | safe }}
{%- else -%} {%- else -%}
{{ text }} {{ text }}
{%- endif %} {%- endif %}
{% endmacro %} {% endmacro %}
{% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %} {% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %}
{% if dangerouslyDisableAutoEscaping %} {% if dangerouslyDisableAutoEscaping %}
{% if showTitleAsUrl %} {% if showTitleAsUrl %}
<a href="{{ url }}" <a
style="text-decoration:none; href="{{ url }}"
style="text-decoration:none;
color:#ffffff" color:#ffffff"
target="_blank">{{ title | safe }}</a> target="_blank"
{% else %} >{{ title | safe }}</a
{{ title | safe }} >
{% endif %}
{% else %} {% else %}
{% if showTitleAsUrl %} {{ title | safe }}
<a href="{{ url }}"
style="text-decoration:none;
color:#ffffff"
target="_blank">{{ title }}</a>
{% else %}
{{ title }}
{% endif %}
{% endif %} {% endif %}
{% else %}
{% if showTitleAsUrl %}
<a
href="{{ url }}"
style="text-decoration:none;
color:#ffffff"
target="_blank"
>{{ title }}</a
>
{% else %}
{{ title }}
{% endif %}
{% endif %}
{% endmacro %} {% endmacro %}
{% if loaded %} {% if loaded %}
{% if config.showAsList %} {% if config.showAsList %}
<ul class="newsfeed-list"> <ul class="newsfeed-list">
{% for item in items %} {% for item in items %}
<li> <li>
{% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %} {% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}
<div class="newsfeed-source light small dimmed"> <div class="newsfeed-source light small dimmed">
{% if item.sourceTitle and config.showSourceTitle %} {% if item.sourceTitle and config.showSourceTitle %}
{{ item.sourceTitle }}{% if config.showPublishDate %}, {% else %}:{% endif %} {{ item.sourceTitle }}{% if config.showPublishDate %},{% else %}:{% endif %}
{% endif %} {% endif %}
{% if config.showPublishDate %}{{ item.publishDate }}:{% endif %} {% if config.showPublishDate %}{{ item.publishDate }}:{% endif %}
</div>
{% endif %}
<div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">
{{ escapeTitle(item.title, item.url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}
</div>
{% if config.showDescription %}
<div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
{% if config.truncDescription %}
{{ escapeText(item.description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}
{% else %}
{{ escapeText(item.description, config.dangerouslyDisableAutoEscaping) }}
{% endif %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div>
{% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %}
<div class="newsfeed-source light small dimmed">
{% if sourceTitle and config.showSourceTitle %}
{{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %}, {% else %}:{% endif %}
{% endif %}
{% if config.showPublishDate %}{{ publishDate }}:{% endif %}
</div>
{% endif %}
<div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">
{{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}
</div> </div>
{% if config.showDescription %} {% endif %}
<div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}"> <div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">{{ escapeTitle(item.title, item.url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}</div>
{% if config.truncDescription %} {% if config.showDescription %}
{{ escapeText(description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }} <div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
{% else %} {% if config.truncDescription %}
{{ escapeText(description, config.dangerouslyDisableAutoEscaping) }} {{ escapeText(item.description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}
{% endif %} {% else %}
</div> {{ escapeText(item.description, config.dangerouslyDisableAutoEscaping) }}
{% endif %} {% endif %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div>
{% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %}
<div class="newsfeed-source light small dimmed">
{% if sourceTitle and config.showSourceTitle %}
{{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %},{% else %}:{% endif %}
{% endif %}
{% if config.showPublishDate %}{{ publishDate }}:{% endif %}
</div> </div>
{% endif %} {% endif %}
{% elseif empty %} <div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">{{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}</div>
<div class="small dimmed">{{ "NEWSFEED_NO_ITEMS" | translate | safe }}</div> {% if config.showDescription %}
{% elseif error %} <div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
<div class="small dimmed"> {% if config.truncDescription %}
{{ "MODULE_CONFIG_ERROR" | translate({MODULE_NAME: "Newsfeed", ERROR: error}) | safe }} {{ escapeText(description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}
{% else %}
{{ escapeText(description, config.dangerouslyDisableAutoEscaping) }}
{% endif %}
</div>
{% endif %}
</div> </div>
{% endif %}
{% elseif empty %}
<div class="small dimmed">{{ "NEWSFEED_NO_ITEMS" | translate | safe }}</div>
{% elseif error %}
<div class="small dimmed">{{ "MODULE_CONFIG_ERROR" | translate({MODULE_NAME: "Newsfeed", ERROR: error}) | safe }}</div>
{% else %} {% else %}
<div class="small dimmed">{{ "LOADING" | translate | safe }}</div> <div class="small dimmed">{{ "LOADING" | translate | safe }}</div>
{% endif %} {% endif %}

View File

@@ -5,6 +5,8 @@ const iconv = require("iconv-lite");
const { htmlToText } = require("html-to-text"); const { htmlToText } = require("html-to-text");
const Log = require("logger"); const Log = require("logger");
const NodeHelper = require("node_helper"); 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. * Responsible for requesting an update on the set interval and broadcasting the data.
@@ -79,12 +81,12 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
parser.on("error", (error) => { parser.on("error", (error) => {
fetchFailedCallback(this, error); fetchFailedCallback(this, error);
scheduleTimer(); scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);
}); });
//"end" event is not broadcast if the feed is empty but "finish" is used for both //"end" event is not broadcast if the feed is empty but "finish" is used for both
parser.on("finish", () => { parser.on("finish", () => {
scheduleTimer(); scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);
}); });
parser.on("ttl", (minutes) => { parser.on("ttl", (minutes) => {
@@ -100,9 +102,8 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
} }
}); });
const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
const headers = { const headers = {
"User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`, "User-Agent": getUserAgent(),
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate", "Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
Pragma: "no-cache" Pragma: "no-cache"
}; };
@@ -120,23 +121,10 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
}) })
.catch((error) => { .catch((error) => {
fetchFailedCallback(this, error); fetchFailedCallback(this, error);
scheduleTimer(); scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);
}); });
}; };
/**
* Schedule the timer for the next update.
*/
const scheduleTimer = function () {
if (process.env.JEST_WORKER_ID === undefined) {
// only set timer when not running in jest
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchNews();
}, reloadIntervalMS);
}
};
/* public methods */ /* public methods */
/** /**

View File

@@ -1,3 +1 @@
<div class="small bright"> <div class="small bright">{{ "MODULE_CONFIG_CHANGED" | translate({MODULE_NAME: "Newsfeed"}) | safe }}</div>
{{ "MODULE_CONFIG_CHANGED" | translate({MODULE_NAME: "Newsfeed"}) | safe }}
</div>

View File

@@ -4,8 +4,6 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const Log = require("logger"); const Log = require("logger");
const BASE_DIR = path.normalize(`${__dirname}/../../../`);
class GitHelper { class GitHelper {
constructor () { constructor () {
this.gitRepos = []; this.gitRepos = [];
@@ -35,10 +33,10 @@ class GitHelper {
} }
async add (moduleName) { async add (moduleName) {
let moduleFolder = BASE_DIR; let moduleFolder = `${global.root_path}`;
if (moduleName !== "MagicMirror") { if (moduleName !== "MagicMirror") {
moduleFolder = `${moduleFolder}modules/${moduleName}`; moduleFolder = `${moduleFolder}/modules/${moduleName}`;
} }
try { try {

View File

@@ -1,7 +1,8 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const NodeHelper = require("node_helper"); const NodeHelper = require("node_helper");
const defaultModules = require("../defaultmodules");
const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);
const GitHelper = require("./git_helper"); const GitHelper = require("./git_helper");
const UpdateHelper = require("./update_helper"); const UpdateHelper = require("./update_helper");
@@ -21,7 +22,7 @@ module.exports = NodeHelper.create({
return modules; return modules;
} else { } else {
// get modules from modules-directory // get modules from modules-directory
const moduleDir = path.normalize(`${__dirname}/../../`); const moduleDir = path.normalize(`${global.root_path}/modules`);
const getDirectories = (source) => { const getDirectories = (source) => {
return fs.readdirSync(source, { withFileTypes: true }) return fs.readdirSync(source, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory() && dirent.name !== "default") .filter((dirent) => dirent.isDirectory() && dirent.name !== "default")

View File

@@ -1,41 +1,41 @@
{% if not suspended %} {% if not suspended %}
{% if needRestart %} {% if needRestart %}
<div class="small bright"> <div class="small bright">
<i class="fas fa-rotate"></i> <i class="fas fa-rotate"></i>
<span> <span>
{% set restartTextLabel = "UPDATE_NOTIFICATION_NEED-RESTART" %} {% set restartTextLabel = "UPDATE_NOTIFICATION_NEED-RESTART" %}
{{ restartTextLabel | translate() | safe }} {{ restartTextLabel | translate() | safe }}
</span> </span>
</div> </div>
{% endif %} {% endif %}
{% for name, status in moduleList %} {% for name, status in moduleList %}
<div class="small bright"> <div class="small bright">
<i class="fas fa-exclamation-circle"></i> <i class="fas fa-exclamation-circle"></i>
<span> <span>
{% set mainTextLabel = "UPDATE_NOTIFICATION" if name === "MagicMirror" else "UPDATE_NOTIFICATION_MODULE" %} {% set mainTextLabel = "UPDATE_NOTIFICATION" if name === "MagicMirror" else "UPDATE_NOTIFICATION_MODULE" %}
{{ mainTextLabel | translate({MODULE_NAME: name}) }} {{ mainTextLabel | translate({MODULE_NAME: name}) }}
</span> </span>
</div> </div>
<div class="xsmall dimmed"> <div class="xsmall dimmed">
{% set subTextLabel = "UPDATE_INFO_SINGLE" if status.behind === 1 else "UPDATE_INFO_MULTIPLE" %} {% set subTextLabel = "UPDATE_INFO_SINGLE" if status.behind === 1 else "UPDATE_INFO_MULTIPLE" %}
{{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }} {{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }}
</div> </div>
{% endfor %} {% endfor %}
{% for name, status in updatesList %} {% for name, status in updatesList %}
<div class="small bright"> <div class="small bright">
{% if status.done %} {% if status.done %}
<i class="fas fa-check" style="color: lightgreen;"></i> <i class="fas fa-check" style="color: lightgreen;"></i>
<span> <span>
{% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %} {% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %}
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }} {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
</span> </span>
{% else %} {% else %}
<i class="fas fa-xmark" style="color: red;"></i> <i class="fas fa-xmark" style="color: red;"></i>
<span> <span>
{% set updateTextLabel = "UPDATE_NOTIFICATION_ERROR" %} {% set updateTextLabel = "UPDATE_NOTIFICATION_ERROR" %}
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }} {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
</span> </span>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@@ -1,101 +1,97 @@
{% macro humidity() %} {% macro humidity() %}
{% if current.humidity %} {% if current.humidity %}
<span class="humidity"><span>{{ current.humidity | decimalSymbol }}</span><sup>&nbsp;<i class="wi wi-humidity humidity-icon"></i></sup></span> <span class="humidity"
{% endif %} ><span>{{ current.humidity | decimalSymbol }}</span><sup>&nbsp;<i class="wi wi-humidity humidity-icon"></i></sup
></span>
{% endif %}
{% endmacro %} {% endmacro %}
{% if current %} {% if current %}
{% if not config.onlyTemp %} {% if not config.onlyTemp %}
<div class="normal medium"> <div class="normal medium">
<span class="wi wi-strong-wind dimmed"></span> <span class="wi wi-strong-wind dimmed"></span>
<span> <span>
{{ current.windSpeed | unit("wind") | round }} {{ current.windSpeed | unit("wind") | round }}
{% if config.showWindDirection %} {% if config.showWindDirection %}
<sup> <sup>
{% if config.showWindDirectionAsArrow %} {% if config.showWindDirectionAsArrow %}
<i class="fas fa-long-arrow-alt-down" style="transform:rotate({{ current.windFromDirection }}deg)"></i> <i class="fas fa-long-arrow-alt-down" style="transform:rotate({{ current.windFromDirection }}deg)"></i>
{% else %} {% else %}
{{ current.cardinalWindDirection() | translate }} {{ current.cardinalWindDirection() | translate }}
{% endif %}
&nbsp;
</sup>
{% endif %}
</span>
{% if config.showHumidity === "wind" %}
{{ humidity() }}
{% endif %} {% endif %}
{% if config.showSun %} &nbsp;
<span class="wi dimmed wi-{{ current.nextSunAction() }}"></span> </sup>
<span>
{% if current.nextSunAction() === "sunset" %}
{{ current.sunset | formatTime }}
{% else %}
{{ current.sunrise | formatTime }}
{% endif %}
</span>
{% endif %}
{% if config.showUVIndex %}
<td class="align-right bright uv-index">
<div class="wi dimmed wi-hot"></div>
{{ current.uv_index }}
</td>
{% endif %}
</div>
{% endif %}
<div class="large">
{% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}
<span class="medium fas fa-home"></span>
<span style="display: inline-block">
{% if config.showIndoorTemperature and indoor.temperature %}
<sup class="small" style="position: relative; display: block; text-align: left;">
<span>
{{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }}
</span>
</sup>
{% endif %}
{% if config.showIndoorHumidity and indoor.humidity %}
<sub class="small" style="position: relative; display: block; text-align: left;">
<span>
{{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }}
</span>
</sub>
{% endif %}
</span>
{% endif %}
<span class="light wi weathericon wi-{{ current.weatherType }}"></span>
<span class="light bright">{{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}</span>
{% if config.showHumidity === "temp" %}
<span class="medium bright">{{ humidity() }}</span>
{% endif %} {% endif %}
</span>
{% if config.showHumidity === "wind" %}
{{ humidity() }}
{% endif %}
{% if config.showSun %}
<span class="wi dimmed wi-{{ current.nextSunAction() }}"></span>
<span>
{% if current.nextSunAction() === "sunset" %}
{{ current.sunset | formatTime }}
{% else %}
{{ current.sunrise | formatTime }}
{% endif %}
</span>
{% endif %}
{% if config.showUVIndex %}
<td class="align-right bright uv-index">
<div class="wi dimmed wi-hot"></div>
{{ current.uv_index }}
</td>
{% endif %}
</div> </div>
{% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %} {% endif %}
<div class="normal medium feelslike"> <div class="flex large type-temp">
{% if config.showFeelsLike %} {% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}
<span class="dimmed"> <span class="medium fas fa-home"></span>
{% if config.showHumidity === "feelslike" %} <span style="display: inline-block">
{{ humidity() }} {% if config.showIndoorTemperature and indoor.temperature %}
{% endif %} <sup class="small" style="position: relative; display: block; text-align: left;">
{{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }} <span> {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} </span>
</span> </sup>
<br /> {% endif %}
{% endif %} {% if config.showIndoorHumidity and indoor.humidity %}
{% if config.showPrecipitationAmount and current.precipitationAmount %} <sub class="small" style="position: relative; display: block; text-align: left;">
<span class="dimmed"> <span> {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} </span>
<span class="precipitationLeadText">{{ "PRECIP_AMOUNT" | translate }}</span> {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }} </sub>
</span> {% endif %}
<br /> </span>
{% endif %}
{% if config.showPrecipitationProbability and current.precipitationProbability %}
<span class="dimmed">
<span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}%
</span>
{% endif %}
</div>
{% endif %} {% endif %}
{% if config.showHumidity === "below" %} {% if current.weatherType %}
<span class="medium dimmed">{{ humidity() }}</span> <span class="light wi weathericon wi-{{ current.weatherType }}"></span>
{% endif %} {% endif %}
<span class="light bright">{{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}</span>
{% if config.showHumidity === "temp" %}
<span class="medium bright">{{ humidity() }}</span>
{% endif %}
</div>
{% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}
<div class="normal medium feelslike">
{% if config.showFeelsLike %}
<span class="dimmed">
{% if config.showHumidity === "feelslike" %}
{{ humidity() }}
{% endif %}
{{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }}
</span>
<br />
{% endif %}
{% if config.showPrecipitationAmount and current.precipitationAmount %}
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_AMOUNT" | translate }}</span> {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }} </span>
<br />
{% endif %}
{% if config.showPrecipitationProbability and current.precipitationProbability %}
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}% </span>
{% endif %}
</div>
{% endif %}
{% if config.showHumidity === "below" %}
<span class="medium dimmed">{{ humidity() }}</span>
{% endif %}
{% else %} {% else %}
<div class="dimmed light small">{{ "LOADING" | translate }}</div> <div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %} {% endif %}
<!-- Uncomment the line below to see the contents of the `current` object. --> <!-- Uncomment the line below to see the contents of the `current` object. -->
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{current | dump}}</div> --> <!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ current | dump }}</div> -->

View File

@@ -1,52 +1,46 @@
{% if forecast %} {% if forecast %}
{% set numSteps = forecast | calcNumSteps %} {% set numSteps = forecast | calcNumSteps %}
{% set currentStep = 0 %} {% set currentStep = 0 %}
<table class="{{ config.tableClass }}"> <table class="{{ config.tableClass }}">
{% if config.ignoreToday %} {% if config.ignoreToday %}
{% set forecast = forecast.splice(1) %} {% set forecast = forecast.splice(1) %}
{% endif %}
{% set forecast = forecast.slice(0, numSteps) %}
{% for f in forecast %}
<tr
{% if config.colored %}class="colored"{% endif %}
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}
>
{% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %}
<td class="day">{{ "TODAY" | translate }}</td>
{% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %}
<td class="day">{{ "TOMORROW" | translate }}</td>
{% else %}
<td class="day">{{ f.date.format("ddd") }}</td>
{% endif %} {% endif %}
{% set forecast = forecast.slice(0, numSteps) %} <td class="bright weather-icon">
{% for f in forecast %} <span class="wi weathericon wi-{{ f.weatherType }}"></span>
<tr {% if config.colored %}class="colored"{% endif %} </td>
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}> <td class="align-right bright max-temp">{{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
{% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %} <td class="align-right min-temp">{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
<td class="day">{{ "TODAY" | translate }}</td> {% if config.showPrecipitationAmount %}
{% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %} <td class="align-right bright precipitation-amount">{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}</td>
<td class="day">{{ "TOMORROW" | translate }}</td> {% endif %}
{% else %} {% if config.showPrecipitationProbability %}
<td class="day">{{ f.date.format("ddd") }}</td> <td class="align-right bright precipitation-prob">{{ f.precipitationProbability | unit('precip', '%') }}</td>
{% endif %} {% endif %}
<td class="bright weather-icon"> {% if config.showUVIndex %}
<span class="wi weathericon wi-{{ f.weatherType }}"></span> <td class="align-right dimmed uv-index">
</td> {{ f.uv_index }}
<td class="align-right bright max-temp"> <span class="wi dimmed weathericon wi-hot"></span>
{{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }} </td>
</td> {% endif %}
<td class="align-right min-temp"> </tr>
{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }} {% set currentStep = currentStep + 1 %}
</td> {% endfor %}
{% if config.showPrecipitationAmount %} </table>
<td class="align-right bright precipitation-amount">
{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}
</td>
{% endif %}
{% if config.showPrecipitationProbability %}
<td class="align-right bright precipitation-prob">
{{ f.precipitationProbability | unit('precip', '%') }}
</td>
{% endif %}
{% if config.showUVIndex %}
<td class="align-right dimmed uv-index">
{{ f.uv_index }}
<span class="wi dimmed weathericon wi-hot"></span>
</td>
{% endif %}
</tr>
{% set currentStep = currentStep + 1 %}
{% endfor %}
</table>
{% else %} {% else %}
<div class="dimmed light small">{{ "LOADING" | translate }}</div> <div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %} {% endif %}
<!-- Uncomment the line below to see the contents of the `forecast` object. --> <!-- Uncomment the line below to see the contents of the `forecast` object. -->
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{forecast | dump}}</div> --> <!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ forecast | dump }}</div> -->

View File

@@ -1,52 +1,48 @@
{% if hourly %} {% if hourly %}
{% set numSteps = hourly | calcNumEntries %} {% set numSteps = hourly | calcNumEntries %}
{% set currentStep = 0 %} {% set currentStep = 0 %}
<table class="{{ config.tableClass }}"> <table class="{{ config.tableClass }}">
{% set hours = hourly.slice(0, numSteps) %} {% set hours = hourly.slice(0, numSteps) %}
{% for hour in hours %} {% for hour in hours %}
<tr {% if config.colored %}class="colored"{% endif %} <tr
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}> {% if config.colored %}class="colored"{% endif %}
<td class="day">{{ hour.date | formatTime }}</td> {% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}
<td class="bright weather-icon"> >
<span class="wi weathericon wi-{{ hour.weatherType }}"></span> <td class="day">{{ hour.date | formatTime }}</td>
</td> <td class="bright weather-icon">
<td class="align-right bright"> <span class="wi weathericon wi-{{ hour.weatherType }}"></span>
{{ hour.temperature | roundValue | unit("temperature") }} </td>
</td> <td class="align-right bright">{{ hour.temperature | roundValue | unit("temperature") }}</td>
{% if config.showUVIndex %} {% if config.showUVIndex %}
<td class="align-right bright uv-index"> <td class="align-right bright uv-index">
{% if hour.uv_index!=0 %} {% if hour.uv_index!=0 %}
{{ hour.uv_index }} {{ hour.uv_index }}
<span class="wi weathericon wi-hot"></span> <span class="wi weathericon wi-hot"></span>
{% endif %} {% endif %}
</td> </td>
{% endif %} {% endif %}
{% if config.showHumidity != "none" %} {% if config.showHumidity != "none" %}
<td class="align-left bright humidity-hourly"> <td class="align-left bright humidity-hourly">
{{ hour.humidity }} {{ hour.humidity }}
<span class="wi wi-humidity humidity-icon"></span> <span class="wi wi-humidity humidity-icon"></span>
</td> </td>
{% endif %} {% endif %}
{% if config.showPrecipitationAmount %} {% if config.showPrecipitationAmount %}
{% if (not config.hideZeroes or hour.precipitationAmount>0) %} {% if (not config.hideZeroes or hour.precipitationAmount>0) %}
<td class="align-right bright precipitation-amount"> <td class="align-right bright precipitation-amount">{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}</td>
{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }} {% endif %}
</td> {% endif %}
{% endif %} {% if config.showPrecipitationProbability %}
{% endif %} {% if (not config.hideZeroes or hour.precipitationAmount>0) %}
{% if config.showPrecipitationProbability %} <td class="align-right bright precipitation-prob">{{ hour.precipitationProbability | unit('precip', '%') }}</td>
{% if (not config.hideZeroes or hour.precipitationAmount>0) %} {% endif %}
<td class="align-right bright precipitation-prob"> {% endif %}
{{ hour.precipitationProbability | unit('precip', '%') }} </tr>
</td> {% set currentStep = currentStep + 1 %}
{% endif %} {% endfor %}
{% endif %} </table>
</tr>
{% set currentStep = currentStep + 1 %}
{% endfor %}
</table>
{% else %} {% else %}
<div class="dimmed light small">{{ "LOADING" | translate }}</div> <div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %} {% endif %}
<!-- Uncomment the line below to see the contents of the `hourly` object. --> <!-- Uncomment the line below to see the contents of the `hourly` object. -->
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{hourly | dump}}</div> --> <!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ hourly | dump }}</div> -->

View File

@@ -24,6 +24,8 @@
* with locations you can search under column B (English Names), with the corresponding siteCode under * with locations you can search under column B (English Names), with the corresponding siteCode under
* column A (Codes) and provCode under column C (Province). * 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: * License to use Environment Canada (EC) data is detailed here:
* https://eccc-msc.github.io/open-data/licence/readme_en/ * https://eccc-msc.github.io/open-data/licence/readme_en/
*/ */
@@ -49,6 +51,9 @@ WeatherProvider.register("envcanada", {
this.todayTempCacheMax = 0; this.todayTempCacheMax = 0;
this.todayCached = false; this.todayCached = false;
this.cacheCurrentTemp = 999; this.cacheCurrentTemp = 999;
this.lastCityPageCurrent = " ";
this.lastCityPageForecast = " ";
this.lastCityPageHourly = " ";
}, },
/* /*
@@ -63,69 +68,158 @@ WeatherProvider.register("envcanada", {
* Override the fetchCurrentWeather method to query EC and construct a Current weather object * Override the fetchCurrentWeather method to query EC and construct a Current weather object
*/ */
fetchCurrentWeather () { fetchCurrentWeather () {
this.fetchData(this.getUrl(), "xml") this.fetchCommon("Current");
.then((data) => {
if (!data) {
// Did not receive usable new data.
return;
}
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load EnvCanada site data ... ", request);
})
.finally(() => this.updateAvailable());
}, },
/* /*
* Override the fetchWeatherForecast method to query EC and construct Forecast weather objects * Override the fetchWeatherForecast method to query EC and construct Forecast/Daily weather objects
*/ */
fetchWeatherForecast () { fetchWeatherForecast () {
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
return;
}
const forecastWeather = this.generateWeatherObjectsFromForecast(data);
this.setWeatherForecast(forecastWeather); this.fetchCommon("Forecast");
})
.catch(function (request) {
Log.error("Could not load EnvCanada forecast data ... ", request);
})
.finally(() => this.updateAvailable());
}, },
/* /*
* Override the fetchWeatherHourly method to query EC and construct Forecast weather objects * Override the fetchWeatherHourly method to query EC and construct Hourly weather objects
*/ */
fetchWeatherHourly () { fetchWeatherHourly () {
this.fetchData(this.getUrl(), "xml") this.fetchCommon("Hourly");
.then((data) => {
if (!data) {
// Did not receive usable new data.
return;
}
const hourlyWeather = this.generateWeatherObjectsFromHourly(data);
this.setWeatherHourly(hourlyWeather);
})
.catch(function (request) {
Log.error("Could not load EnvCanada hourly data ... ", request);
})
.finally(() => this.updateAvailable());
}, },
/* /*
* Build the EC URL based on the Site Code and Province Code specified in the config params. Note that the * Because the process to fetch weather data is virtually the same for Current, Forecast/Daily, and Hourly weather,
* URL defaults to the English version simply because there is no language dependency in the data * a common module is used to access the EC weather data. The only customization (based on the caller of this routine)
* being accessed. This is only pertinent when using the EC data elements that contain a textual forecast. * 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 approriate URL for the MSC Datamart Index page
Log.debug(`[weather.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(`weather.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 unqiue 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(`[weather.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(`[weather.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(`[weather.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(`[weather.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(`weather.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(`[weather.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(`weather.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(`weather.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 () { getUrl () {
return `https://dd.weather.gc.ca/citypage_weather/xml/${this.config.provCode}/${this.config.siteCode}_e.xml`; let forecastURL = `https://dd.weather.gc.ca/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
}, },
/* /*
@@ -151,7 +245,6 @@ WeatherProvider.register("envcanada", {
} }
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent); currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent; currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent;
currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent; currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent;
@@ -214,7 +307,7 @@ WeatherProvider.register("envcanada", {
/* /*
* The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing * 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 * 2 elements. the first element for a day details the Today (daytime) forecast while the second
* element details the Tonight (nightime) forecast. Element 0 is always for the current day. * element details the Tonight (nighttime) forecast. Element 0 is always for the current day.
* *
* However... the forecast is somewhat 'rolling'. * However... the forecast is somewhat 'rolling'.
* *
@@ -225,7 +318,7 @@ WeatherProvider.register("envcanada", {
* *
* But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled * 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 * 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, Elelement 11 will contain a forecast for a 6th day, * 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 * 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 Todat forecast in Element 11. * Elements 0 to 11, and will ignore the additional Todat forecast in Element 11.
* *
@@ -436,17 +529,17 @@ WeatherProvider.register("envcanada", {
* then it will be displayed ONLY if no POP is present. * 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 * 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 nightime portions * 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 * of each day, the weather module does not really allow for that view of a daily forecast. There we will
* ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show
* the nightime forecast after a certain point in the afternoon. As such, we will be showing the nightime POP * 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. * (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 * 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 nightime portions * 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 * of each day, the weather module does not really allow for that view of a daily forecast. There we will
* ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show
* the nightime forecast after a certain point in that specific scenario. * the nighttime forecast after a certain point in that specific scenario.
*/ */
setPrecipitation (weather, foreGroup, today) { setPrecipitation (weather, foreGroup, today) {
if (foreGroup[today].querySelector("precipitation accumulation")) { if (foreGroup[today].querySelector("precipitation accumulation")) {

View File

@@ -13,7 +13,7 @@ WeatherProvider.register("openmeteo", {
/* /*
* Set the name of the provider. * Set the name of the provider.
* Not strictly required, but helps for debugging. * Not strictly required but helps for debugging.
*/ */
providerName: "Open-Meteo", providerName: "Open-Meteo",
@@ -348,7 +348,7 @@ WeatherProvider.register("openmeteo", {
generateWeatherDayFromCurrentWeather (weather) { generateWeatherDayFromCurrentWeather (weather) {
/** /**
* Since some units comes from API response "splitted" into daily, hourly and current_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. * 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): * For the current weather case, the response have the following structure (after transposing):
* ``` * ```
@@ -381,6 +381,7 @@ WeatherProvider.register("openmeteo", {
currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max); currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max);
currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime()); currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime());
currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m); currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m);
currentWeather.feelsLikeTemp = parseFloat(weather.hourly[h].apparent_temperature);
currentWeather.rain = parseFloat(weather.hourly[h].rain); currentWeather.rain = parseFloat(weather.hourly[h].rain);
currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10); currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10);
currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation); currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation);

View File

@@ -254,7 +254,7 @@ WeatherProvider.register("smhi", {
* Helper method to get a property from the returned data set. * Helper method to get a property from the returned data set.
* @param {object} currentWeatherData Weatherdata to get from * @param {object} currentWeatherData Weatherdata to get from
* @param {string} name The name of the property * @param {string} name The name of the property
* @returns {*} The value of the property in the weatherdata * @returns {string} The value of the property in the weatherdata
*/ */
paramValue (currentWeatherData, name) { paramValue (currentWeatherData, name) {
return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0]; return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0];

View File

@@ -218,7 +218,7 @@ WeatherProvider.register("weathergov", {
currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value; currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value;
currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value; currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value;
currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value); currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value);
currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour.value ? currentWeatherData.precipitationLastHour.value : currentWeatherData.precipitationLast3Hours.value; currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value;
if (currentWeatherData.heatIndex.value !== null) { if (currentWeatherData.heatIndex.value !== null) {
currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value; currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value;
} else if (currentWeatherData.windChill.value !== null) { } else if (currentWeatherData.windChill.value !== null) {

View File

@@ -1,9 +1,6 @@
.weather .weathericon, .weather .weathericon,
.weather .fa-home { .weather .fa-home {
font-size: 75%; font-size: 75%;
line-height: 65px;
display: inline-block;
transform: translate(0, -3px);
} }
.weather .humidity-icon { .weather .humidity-icon {
@@ -37,10 +34,6 @@
padding-right: 0; padding-right: 0;
} }
.weather tr .weathericon {
line-height: 25px;
}
.weather tr.colored .min-temp { .weather tr.colored .min-temp {
color: #bcddff; color: #bcddff;
} }
@@ -48,3 +41,9 @@
.weather tr.colored .max-temp { .weather tr.colored .max-temp {
color: #ff8e99; color: #ff8e99;
} }
.weather .type-temp {
display: flex;
align-items: baseline;
gap: 10px;
}

View File

@@ -163,11 +163,12 @@ Module.register("weather", {
// What to do when the weather provider has new information available? // What to do when the weather provider has new information available?
updateAvailable () { updateAvailable () {
Log.log("New weather information available."); Log.log("New weather information available.");
this.updateDom(0); // this value was changed from 0 to 300 to stabilize weather tests:
this.updateDom(300);
this.scheduleUpdate(); this.scheduleUpdate();
if (this.weatherProvider.currentWeather()) { if (this.weatherProvider.currentWeather()) {
this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType.replace("-", "_") }); this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType?.replace("-", "_") });
} }
const notificationPayload = { const notificationPayload = {

View File

@@ -53,7 +53,7 @@ const WeatherUtils = {
/** /**
* Convert temp (from degrees C) into imperial or metric unit depending on * Convert temp (from degrees C) into imperial or metric unit depending on
* your config * your config
* @param {number} tempInC the temperature in celsius you want to convert * @param {number} tempInC the temperature in Celsius you want to convert
* @param {string} unit can be 'imperial' or 'metric' * @param {string} unit can be 'imperial' or 'metric'
* @returns {number} the converted temperature * @returns {number} the converted temperature
*/ */
@@ -61,6 +61,15 @@ const WeatherUtils = {
return unit === "imperial" ? tempInC * 1.8 + 32 : tempInC; return unit === "imperial" ? tempInC * 1.8 + 32 : tempInC;
}, },
/**
* Convert temp (from degrees C) into metric unit
* @param {number} tempInF the temperature in Fahrenheit you want to convert
* @returns {number} the converted temperature
*/
convertTempToMetric (tempInF) {
return ((tempInF - 32) * 5) / 9;
},
/** /**
* Convert wind speed into another unit. * Convert wind speed into another unit.
* @param {number} windInMS the windspeed in meter/sec you want to convert * @param {number} windInMS the windspeed in meter/sec you want to convert
@@ -118,27 +127,51 @@ const WeatherUtils = {
return kmh * 0.27777777777778; return kmh * 0.27777777777778;
}, },
/**
* Taken from https://community.home-assistant.io/t/calculating-apparent-feels-like-temperature/370834/18
* @param {number} temperature temperature in degrees Celsius
* @param {number} windSpeed wind speed in meter/second
* @param {number} humidity relative humidity in percent
* @returns {number} the feels like temperature in degrees Celsius
*/
calculateFeelsLike (temperature, windSpeed, humidity) { calculateFeelsLike (temperature, windSpeed, humidity) {
const windInMph = this.convertWind(windSpeed, "imperial"); const windInMph = this.convertWind(windSpeed, "imperial");
const tempInF = this.convertTemp(temperature, "imperial"); const tempInF = this.convertTemp(temperature, "imperial");
let feelsLike = tempInF;
if (windInMph > 3 && tempInF < 50) { let HI;
feelsLike = Math.round(35.74 + 0.6215 * tempInF - 35.75 * Math.pow(windInMph, 0.16) + 0.4275 * tempInF * Math.pow(windInMph, 0.16)); let WC = tempInF;
} else if (tempInF > 80 && humidity > 40) {
feelsLike // Calculate wind chill for certain conditions
= -42.379 if (tempInF <= 70 && windInMph >= 3) {
+ 2.04901523 * tempInF WC = 35.74 + (0.6215 * tempInF) - 35.75 * Math.pow(windInMph, 0.16) + ((0.4275 * tempInF) * Math.pow(windInMph, 0.16));
+ 10.14333127 * humidity
- 0.22475541 * tempInF * humidity
- 6.83783 * Math.pow(10, -3) * tempInF * tempInF
- 5.481717 * Math.pow(10, -2) * humidity * humidity
+ 1.22874 * Math.pow(10, -3) * tempInF * tempInF * humidity
+ 8.5282 * Math.pow(10, -4) * tempInF * humidity * humidity
- 1.99 * Math.pow(10, -6) * tempInF * tempInF * humidity * humidity;
} }
return ((feelsLike - 32) * 5) / 9; // Steadman Heat Index Vorberechnung
const STEADMAN_HI = 0.5 * (tempInF + 61.0 + ((tempInF - 68.0) * 1.2) + (humidity * 0.094));
if (STEADMAN_HI >= 80) {
// Rothfusz-Komplex
const ROTHFUSZ_HI = -42.379 + 2.04901523 * tempInF + 10.14333127 * humidity - 0.22475541 * tempInF * humidity - 0.00683783 * tempInF * tempInF - 0.05481717 * humidity * humidity + 0.00122874 * tempInF * tempInF * humidity + 0.00085282 * tempInF * humidity * humidity - 0.00000199 * tempInF * tempInF * humidity * humidity;
HI = ROTHFUSZ_HI;
if (humidity < 13 && tempInF > 80 && tempInF < 112) {
const ADJUSTMENT = ((13 - humidity) / 4) * Math.pow(Math.abs(17 - (tempInF - 95)), 0.5) / 17; // sqrt Teil
HI = HI - ADJUSTMENT;
} else if (humidity > 85 && tempInF > 80 && tempInF < 87) {
const ADJUSTMENT = ((humidity - 85) / 10) * ((87 - tempInF) / 5);
HI = HI + ADJUSTMENT;
}
} else { HI = STEADMAN_HI; }
// Feuchte Lastberechnung FL
let FL;
if (tempInF < 50) { FL = WC; }
else if (tempInF >= 50 && tempInF < 70) { FL = ((70 - tempInF) / 20) * WC + ((tempInF - 50) / 20) * HI; }
else if (tempInF >= 70) { FL = HI; }
return this.convertTempToMetric(FL);
}, },
/** /**

3367
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "magicmirror", "name": "magicmirror",
"version": "2.32.0", "version": "2.33.0",
"description": "The open source modular smart mirror platform.", "description": "The open source modular smart mirror platform.",
"keywords": [ "keywords": [
"magic mirror", "magic mirror",
@@ -23,16 +23,24 @@
"https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors" "https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors"
], ],
"type": "commonjs", "type": "commonjs",
"imports": {
"#module_functions": {
"default": "./js/module_functions.js"
},
"#server_functions": {
"default": "./js/server_functions.js"
}
},
"main": "js/electron.js", "main": "js/electron.js",
"scripts": { "scripts": {
"config:check": "node js/check_config.js", "config:check": "node js/check_config.js",
"postinstall": "git clean -df fonts vendor",
"install-mm": "npm install --no-audit --no-fund --no-update-notifier --only=prod --omit=dev", "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", "install-mm:dev": "npm install --no-audit --no-fund --no-update-notifier",
"lint:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css' --fix", "lint:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css' --fix",
"lint:js": "eslint --fix", "lint:js": "eslint --fix",
"lint:markdown": "markdownlint-cli2 . --fix", "lint:markdown": "markdownlint-cli2 . --fix",
"lint:prettier": "prettier . --write", "lint:prettier": "prettier . --write",
"postinstall": "git clean -df fonts vendor",
"prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.", "prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.",
"server": "node ./serveronly", "server": "node ./serveronly",
"start": "node --run start:x11", "start": "node --run start:x11",
@@ -56,63 +64,63 @@
"test:unit": "NODE_ENV=test jest --selectProjects unit" "test:unit": "NODE_ENV=test jest --selectProjects unit"
}, },
"lint-staged": { "lint-staged": {
"*": "prettier --write", "*": "prettier --ignore-unknown --write",
"*.js": "eslint --fix", "*.js": "eslint --fix",
"*.css": "stylelint --fix" "*.css": "stylelint --fix"
}, },
"dependencies": { "dependencies": {
"@fontsource/roboto": "^5.2.6", "@fontsource/roboto": "^5.2.8",
"@fontsource/roboto-condensed": "^5.2.6", "@fontsource/roboto-condensed": "^5.2.8",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^7.0.1",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"console-stamp": "^3.1.2", "console-stamp": "^3.1.2",
"croner": "^9.1.0", "croner": "^9.1.0",
"envsub": "^4.1.0", "envsub": "^4.1.0",
"eslint": "^9.30.0", "eslint": "^9.36.0",
"express": "^5.1.0", "express": "^5.1.0",
"express-ipfilter": "^1.3.2", "express-ipfilter": "^1.3.2",
"feedme": "^2.0.2", "feedme": "^2.0.2",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.7.0",
"module-alias": "^2.2.3", "module-alias": "^2.2.3",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.6.0", "moment-timezone": "^0.6.0",
"node-ical": "^0.20.1", "node-ical": "^0.21.0",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
"pm2": "^6.0.8", "pm2": "^6.0.13",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"suncalc": "^1.9.0", "suncalc": "^1.9.0",
"systeminformation": "^5.27.7", "systeminformation": "^5.27.10",
"undici": "^7.11.0", "undici": "^7.16.0",
"weathericons": "^2.1.0" "weathericons": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/eslint-plugin": "^5.1.0", "@stylistic/eslint-plugin": "^5.4.0",
"cspell": "^9.1.2", "cspell": "^9.2.1",
"eslint-plugin-import-x": "^4.16.1", "eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jest": "^29.0.1", "eslint-plugin-jest": "^29.0.1",
"eslint-plugin-jsdoc": "^51.2.3", "eslint-plugin-jsdoc": "^60.1.1",
"eslint-plugin-package-json": "^0.42.0", "eslint-plugin-package-json": "^0.56.3",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^30.0.3", "jest": "^30.1.3",
"jsdom": "^26.1.0", "jsdom": "^27.0.0",
"lint-staged": "^16.1.2", "lint-staged": "^16.2.0",
"markdownlint-cli2": "^0.18.1", "markdownlint-cli2": "^0.18.1",
"playwright": "^1.53.1", "playwright": "^1.55.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"sinon": "^21.0.0", "prettier-plugin-jinja-template": "^2.1.0",
"stylelint": "^16.21.0", "stylelint": "^16.24.0",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^39.0.0",
"stylelint-prettier": "^5.0.3" "stylelint-prettier": "^5.0.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"electron": "^36.6.0" "electron": "^38.1.2"
}, },
"engines": { "engines": {
"node": ">=22.14.0" "node": ">=22.18.0"
}, },
"_moduleAliases": { "_moduleAliases": {
"node_helper": "js/node_helper.js", "node_helper": "js/node_helper.js",

View File

@@ -1,10 +1,17 @@
const config = { const config = {
plugins: ["prettier-plugin-jinja-template"],
overrides: [ overrides: [
{ {
files: "*.md", files: "*.md",
options: { options: {
parser: "markdown" parser: "markdown"
} }
},
{
files: ["*.njk"],
options: {
parser: "jinja-template"
}
} }
], ],
trailingComma: "none" trailingComma: "none"

View File

@@ -0,0 +1,18 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
modules: [
{
module: "alert",
config: {
display_time: 1000000,
welcome_message: false
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,18 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
modules: [
{
module: "alert",
config: {
display_time: 1000000,
welcome_message: "Custom welcome message!"
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -1,43 +1,79 @@
const helpers = require("./helpers/global-setup"); const helpers = require("./helpers/global-setup");
// Validate Animate.css integration for compliments module using class toggling.
// We intentionally ignore computed animation styles (jsdom doesn't simulate real animations).
describe("AnimateCSS integration Test", () => { describe("AnimateCSS integration Test", () => {
// define config file for testing // Config variants under test
let testConfigFile = "tests/configs/modules/compliments/compliments_animateCSS.js"; const TEST_CONFIG_ANIM = "tests/configs/modules/compliments/compliments_animateCSS.js";
// define config file to fallback to default: wrong animation name (must return no animation) const TEST_CONFIG_FALLBACK = "tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js"; // invalid animation names
let testConfigFileFallbackToDefault = "tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js"; const TEST_CONFIG_INVERTED = "tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js"; // in/out swapped
// define config file with an inverted name animation : in for out and vice versa (must return no animation) const TEST_CONFIG_NONE = "tests/configs/modules/compliments/compliments_anytime.js"; // no animations defined
let testConfigFileInvertedAnimationName = "tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js";
// define config file with no animation defined
let testConfigByDefault = "tests/configs/modules/compliments/compliments_anytime.js";
/** /**
* move similar tests in function doTest * Get the compliments container element (waits until available).
* @param {string} [animationIn] animation in name of AnimateCSS to test. * @returns {Promise<HTMLElement>} compliments root element
* @param {string} [animationOut] animation out name of AnimateCSS to test.
* @returns {boolean} result
*/ */
const doTest = async (animationIn, animationOut) => { async function getComplimentsElement () {
await helpers.getDocument(); await helpers.getDocument();
let elem = await helpers.waitForElement(".compliments"); const el = await helpers.waitForElement(".compliments");
expect(elem).not.toBeNull(); expect(el).not.toBeNull();
let styles = window.getComputedStyle(elem); return el;
}
if (animationIn && animationIn !== "") { /**
expect(styles._values.get("animation-name")).toBe(animationIn); * Wait for an Animate.css class to appear and persist briefly.
} else { * @param {string} cls Animation class name without leading dot (e.g. animate__flipInX)
expect(styles._values.get("animation-name")).toBeUndefined(); * @param {{timeout?: number}} [options] Poll timeout in ms (default 6000)
* @returns {Promise<boolean>} true if class detected in time
*/
async function waitForAnimationClass (cls, { timeout = 6000 } = {}) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (document.querySelector(`.compliments.animate__animated.${cls}`)) {
// small stability wait
await new Promise((r) => setTimeout(r, 50));
if (document.querySelector(`.compliments.animate__animated.${cls}`)) return true;
}
await new Promise((r) => setTimeout(r, 100));
} }
throw new Error(`Timeout waiting for class ${cls}`);
}
if (animationOut && animationOut !== "") { /**
elem = await helpers.waitForElement(`.compliments.animate__animated.animate__${animationOut}`); * Assert that no Animate.css animation class is applied within a time window.
expect(elem).not.toBeNull(); * @param {number} [ms] Observation period in ms (default 2000)
styles = window.getComputedStyle(elem); * @returns {Promise<void>}
expect(styles._values.get("animation-name")).toBe(animationOut); */
} else { async function assertNoAnimationWithin (ms = 2000) {
expect(styles._values.get("animation-name")).toBeUndefined(); const start = Date.now();
while (Date.now() - start < ms) {
if (document.querySelector(".compliments.animate__animated")) {
throw new Error("Unexpected animate__animated class present in non-animation scenario");
}
await new Promise((r) => setTimeout(r, 100));
}
}
/**
* Run one animation test scenario.
* @param {string} [animationIn] Expected animate-in name
* @param {string} [animationOut] Expected animate-out name
* @returns {Promise<boolean>} true when scenario assertions pass
*/
async function runAnimationTest (animationIn, animationOut) {
await getComplimentsElement();
if (!animationIn && !animationOut) {
await assertNoAnimationWithin(2000);
return true;
}
if (animationIn) await waitForAnimationClass(`animate__${animationIn}`);
if (animationOut) {
// Wait just beyond one update cycle (updateInterval=2000ms) before expecting animateOut.
await new Promise((r) => setTimeout(r, 2100));
await waitForAnimationClass(`animate__${animationOut}`);
} }
return true; return true;
}; }
afterEach(async () => { afterEach(async () => {
await helpers.stopApplication(); await helpers.stopApplication();
@@ -45,29 +81,29 @@ describe("AnimateCSS integration Test", () => {
describe("animateIn and animateOut Test", () => { describe("animateIn and animateOut Test", () => {
it("with flipInX and flipOutX animation", async () => { it("with flipInX and flipOutX animation", async () => {
await helpers.startApplication(testConfigFile); await helpers.startApplication(TEST_CONFIG_ANIM);
await expect(doTest("flipInX", "flipOutX")).resolves.toBe(true); await expect(runAnimationTest("flipInX", "flipOutX")).resolves.toBe(true);
}); });
}); });
describe("use animateOut name for animateIn (vice versa) Test", () => { describe("use animateOut name for animateIn (vice versa) Test", () => {
it("without animation", async () => { it("without animation (inverted names)", async () => {
await helpers.startApplication(testConfigFileInvertedAnimationName); await helpers.startApplication(TEST_CONFIG_INVERTED);
await expect(doTest()).resolves.toBe(true); await expect(runAnimationTest()).resolves.toBe(true);
}); });
}); });
describe("false Animation name test", () => { describe("false Animation name test", () => {
it("without animation", async () => { it("without animation (invalid names)", async () => {
await helpers.startApplication(testConfigFileFallbackToDefault); await helpers.startApplication(TEST_CONFIG_FALLBACK);
await expect(doTest()).resolves.toBe(true); await expect(runAnimationTest()).resolves.toBe(true);
}); });
}); });
describe("no Animation defined test", () => { describe("no Animation defined test", () => {
it("without animation", async () => { it("without animation (no config)", async () => {
await helpers.startApplication(testConfigByDefault); await helpers.startApplication(TEST_CONFIG_NONE);
await expect(doTest()).resolves.toBe(true); await expect(runAnimationTest()).resolves.toBe(true);
}); });
}); });
}); });

View File

@@ -3,7 +3,7 @@ const helpers = require("./helpers/global-setup");
describe("All font files from roboto.css should be downloadable", () => { describe("All font files from roboto.css should be downloadable", () => {
const fontFiles = []; const fontFiles = [];
// Statements below filters out all 'url' lines in the CSS file // Statements below filters out all 'url' lines in the CSS file
const fileContent = require("node:fs").readFileSync(`${__dirname}/../../css/roboto.css`, "utf8"); const fileContent = require("node:fs").readFileSync(`${global.root_path}/css/roboto.css`, "utf8");
const regex = /\burl\(['"]([^'"]+)['"]\)/g; const regex = /\burl\(['"]([^'"]+)['"]\)/g;
let match = regex.exec(fileContent); let match = regex.exec(fileContent);
while (match !== null) { while (match !== null) {

View File

@@ -13,10 +13,9 @@ app.use(basicAuth);
// Set available directories // Set available directories
const directories = ["/tests/configs", "/tests/mocks"]; const directories = ["/tests/configs", "/tests/mocks"];
const rootPath = path.resolve(`${__dirname}/../../../`);
for (let directory of directories) { for (let directory of directories) {
app.use(directory, express.static(path.resolve(rootPath + directory))); app.use(directory, express.static(path.resolve(`${global.root_path}/${directory}`)));
} }
let server; let server;

View File

@@ -1,9 +1,13 @@
const path = require("node:path");
const os = require("node:os"); const os = require("node:os");
const fs = require("node:fs"); const fs = require("node:fs");
const jsdom = require("jsdom"); const jsdom = require("jsdom");
const indexFile = `${__dirname}/../../../index.html`; // global absolute root path
const cssFile = `${__dirname}/../../../css/custom.css`; global.root_path = path.resolve(`${__dirname}/../../../`);
const indexFile = `${global.root_path}/index.html`;
const cssFile = `${global.root_path}/css/custom.css`;
const sampleCss = [ const sampleCss = [
".region.row3 {", ".region.row3 {",
" top: 0;", " top: 0;",
@@ -29,12 +33,12 @@ exports.startApplication = async (configFilename, exec) => {
process.env.mmTestMode = "true"; process.env.mmTestMode = "true";
process.setMaxListeners(0); process.setMaxListeners(0);
if (exec) exec; if (exec) exec;
global.app = require("../../../js/app"); global.app = require(`${global.root_path}/js/app`);
return global.app.start(); return global.app.start();
}; };
exports.stopApplication = async (waitTime = 1000) => { exports.stopApplication = async (waitTime = 10) => {
if (global.window) { if (global.window) {
// no closing causes jest errors and memory leaks // no closing causes jest errors and memory leaks
global.window.close(); global.window.close();

View File

@@ -1,4 +1,4 @@
const { injectMockData } = require("../../utils/weather_mocker"); const { injectMockData, cleanupMockData } = require("../../utils/weather_mocker");
const helpers = require("./global-setup"); const helpers = require("./global-setup");
exports.getText = async (element, result) => { exports.getText = async (element, result) => {
@@ -13,7 +13,12 @@ exports.getText = async (element, result) => {
return true; return true;
}; };
exports.startApp = async (configFileName, additionalMockData) => { exports.startApplication = async (configFileName, additionalMockData) => {
await helpers.startApplication(injectMockData(configFileName, additionalMockData)); await helpers.startApplication(injectMockData(configFileName, additionalMockData));
await helpers.getDocument(); await helpers.getDocument();
}; };
exports.stopApplication = async () => {
await helpers.stopApplication();
cleanupMockData();
};

View File

@@ -1,17 +1,52 @@
const helpers = require("../helpers/global-setup"); const helpers = require("../helpers/global-setup");
describe("Alert module", () => { describe("Alert module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/default.js");
await helpers.getDocument();
});
afterAll(async () => { afterAll(async () => {
await helpers.stopApplication(); await helpers.stopApplication();
}); });
it("should show the welcome message", async () => { describe("with welcome_message set to false", () => {
const elem = await helpers.waitForElement(".ns-box .ns-box-inner .light.bright.small"); beforeAll(async () => {
expect(elem).not.toBeNull(); await helpers.startApplication("tests/configs/modules/alert/welcome_false.js");
expect(elem.textContent).toContain("Welcome, start was successful!"); await helpers.getDocument();
});
it("should not show any welcome message", async () => {
// Wait a bit to ensure no message appears
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check that no alert/notification elements are present
const alertElements = document.querySelectorAll(".ns-box .ns-box-inner .light.bright.small");
expect(alertElements).toHaveLength(0);
});
});
describe("with welcome_message set to true", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/welcome_true.js");
await helpers.getDocument();
// Wait for the application to initialize
await new Promise((resolve) => setTimeout(resolve, 1000));
});
it("should show the translated welcome message", async () => {
const elem = await helpers.waitForElement(".ns-box .ns-box-inner .light.bright.small");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Welcome, start was successful!");
});
});
describe("with welcome_message set to custom string", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/welcome_string.js");
await helpers.getDocument();
});
it("should show the custom welcome message", async () => {
const elem = await helpers.waitForElement(".ns-box .ns-box-inner .light.bright.small");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Custom welcome message!");
});
}); });
}); });

View File

@@ -42,7 +42,7 @@ describe("Calendar module", () => {
}); });
it("should show the default calendar symbol in each event", async () => { it("should show the default calendar symbol in each event", async () => {
await expect(testElementLength(".calendar .event .fa-calendar-alt", 0, "not")).resolves.toBe(true); await expect(testElementLength(".calendar .event .fa-calendar-days", 0, "not")).resolves.toBe(true);
}); });
}); });

View File

@@ -83,8 +83,7 @@ describe("Newsfeed module", () => {
describe("Newsfeed module located in config directory", () => { describe("Newsfeed module located in config directory", () => {
beforeAll(() => { beforeAll(() => {
const baseDir = `${__dirname}/../../..`; fs.cpSync(`${global.root_path}/modules/default/newsfeed`, `${global.root_path}/config/newsfeed`, { recursive: true });
fs.cpSync(`${baseDir}/modules/default/newsfeed`, `${baseDir}/config/newsfeed`, { recursive: true });
process.env.MM_MODULES_DIR = "config"; process.env.MM_MODULES_DIR = "config";
}); });

View File

@@ -1,17 +1,15 @@
const helpers = require("../helpers/global-setup"); const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions"); const weatherFunc = require("../helpers/weather-functions");
const { cleanupMockData } = require("../../utils/weather_mocker");
describe("Weather module", () => { describe("Weather module", () => {
afterAll(async () => { afterAll(async () => {
await helpers.stopApplication(); await weatherFunc.stopApplication();
await cleanupMockData();
}); });
describe("Current weather", () => { describe("Current weather", () => {
describe("Default configuration", () => { describe("Default configuration", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_default.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_default.js", {});
}); });
it("should render wind speed and wind direction", async () => { it("should render wind speed and wind direction", async () => {
@@ -20,12 +18,16 @@ describe("Weather module", () => {
it("should render temperature with icon", async () => { it("should render temperature with icon", async () => {
await expect(weatherFunc.getText(".weather .large span.light.bright", "1.5°")).resolves.toBe(true); await expect(weatherFunc.getText(".weather .large span.light.bright", "1.5°")).resolves.toBe(true);
const elem = await helpers.waitForElement(".weather .large span.weathericon");
expect(elem).not.toBeNull();
}); });
it("should render feels like temperature", async () => { it("should render feels like temperature", async () => {
// Template contains &nbsp; which renders as \xa0 // Template contains &nbsp; which renders as \xa0
await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "93.7\xa0 Feels like -5.6°")).resolves.toBe(true); await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "93.7\xa0 Feels like -5.6°")).resolves.toBe(true);
}); });
it("should render humidity next to feels-like", async () => { it("should render humidity next to feels-like", async () => {
await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed .humidity", "93.7")).resolves.toBe(true); await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed .humidity", "93.7")).resolves.toBe(true);
}); });
@@ -34,7 +36,7 @@ describe("Weather module", () => {
describe("Compliments Integration", () => { describe("Compliments Integration", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_compliments.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_compliments.js", {});
}); });
it("should render a compliment based on the current weather", async () => { it("should render a compliment based on the current weather", async () => {
@@ -44,7 +46,7 @@ describe("Weather module", () => {
describe("Configuration Options", () => { describe("Configuration Options", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_options.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_options.js", {});
}); });
it("should render windUnits in beaufort", async () => { it("should render windUnits in beaufort", async () => {
@@ -72,7 +74,7 @@ describe("Weather module", () => {
describe("Current weather with imperial units", () => { describe("Current weather with imperial units", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_units.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_units.js", {});
}); });
it("should render wind in imperial units", async () => { it("should render wind in imperial units", async () => {

View File

@@ -1,16 +1,14 @@
const helpers = require("../helpers/global-setup"); const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions"); const weatherFunc = require("../helpers/weather-functions");
const { cleanupMockData } = require("../../utils/weather_mocker");
describe("Weather module: Weather Forecast", () => { describe("Weather module: Weather Forecast", () => {
afterAll(async () => { afterAll(async () => {
await helpers.stopApplication(); await weatherFunc.stopApplication();
await cleanupMockData();
}); });
describe("Default configuration", () => { describe("Default configuration", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_default.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_default.js", {});
}); });
const days = ["Today", "Tomorrow", "Sun", "Mon", "Tue"]; const days = ["Today", "Tomorrow", "Sun", "Mon", "Tue"];
@@ -54,7 +52,7 @@ describe("Weather module: Weather Forecast", () => {
describe("Absolute configuration", () => { describe("Absolute configuration", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_absolute.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_absolute.js", {});
}); });
const days = ["Fri", "Sat", "Sun", "Mon", "Tue"]; const days = ["Fri", "Sat", "Sun", "Mon", "Tue"];
@@ -67,7 +65,7 @@ describe("Weather module: Weather Forecast", () => {
describe("Configuration Options", () => { describe("Configuration Options", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_options.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_options.js", {});
}); });
it("should render custom table class", async () => { it("should render custom table class", async () => {
@@ -94,7 +92,7 @@ describe("Weather module: Weather Forecast", () => {
describe("Forecast weather with imperial units", () => { describe("Forecast weather with imperial units", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_units.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_units.js", {});
}); });
describe("Temperature units", () => { describe("Temperature units", () => {

View File

@@ -1,16 +1,13 @@
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions"); const weatherFunc = require("../helpers/weather-functions");
const { cleanupMockData } = require("../../utils/weather_mocker");
describe("Weather module: Weather Hourly Forecast", () => { describe("Weather module: Weather Hourly Forecast", () => {
afterAll(async () => { afterAll(async () => {
await helpers.stopApplication(); await weatherFunc.stopApplication();
await cleanupMockData();
}); });
describe("Default configuration", () => { describe("Default configuration", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/hourlyweather_default.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_default.js", {});
}); });
const minTemps = ["7:00 pm", "8:00 pm", "9:00 pm", "10:00 pm", "11:00 pm"]; const minTemps = ["7:00 pm", "8:00 pm", "9:00 pm", "10:00 pm", "11:00 pm"];
@@ -23,7 +20,7 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Hourly weather options", () => { describe("Hourly weather options", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/hourlyweather_options.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_options.js", {});
}); });
describe("Hourly increments of 2", () => { describe("Hourly increments of 2", () => {
@@ -38,7 +35,7 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Show precipitations", () => { describe("Show precipitations", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", {}); await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", {});
}); });
describe("Shows precipitation amount", () => { describe("Shows precipitation amount", () => {

View File

@@ -2,11 +2,17 @@ const delay = (time) => {
return new Promise((resolve) => setTimeout(resolve, time)); return new Promise((resolve) => setTimeout(resolve, time));
}; };
const runConfigCheck = async () => {
const serverProcess = await require("node:child_process").spawnSync("node", ["--run", "config:check"], { env: process.env });
expect(serverProcess.stderr.toString()).toBe("");
return await serverProcess.status;
};
describe("App environment", () => { describe("App environment", () => {
let serverProcess; let serverProcess;
beforeAll(async () => { beforeAll(async () => {
process.env.MM_CONFIG_FILE = "tests/configs/default.js"; process.env.MM_CONFIG_FILE = "tests/configs/default.js";
serverProcess = await require("node:child_process").spawn("npm", ["run", "server"], { env: process.env, detached: true }); serverProcess = await require("node:child_process").spawn("node", ["--run", "server"], { env: process.env, detached: true });
// we have to wait until the server is started // we have to wait until the server is started
await delay(2000); await delay(2000);
}); });
@@ -24,3 +30,15 @@ describe("App environment", () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe("Check config", () => {
it("config check should return without errors", async () => {
process.env.MM_CONFIG_FILE = "tests/configs/default.js";
await expect(runConfigCheck()).resolves.toBe(0);
});
it("config check should fail with non existent config file", async () => {
process.env.MM_CONFIG_FILE = "tests/configs/not_exists.js";
await expect(runConfigCheck()).resolves.toBe(1);
});
});

View File

@@ -3,9 +3,26 @@ const path = require("node:path");
const helmet = require("helmet"); const helmet = require("helmet");
const { JSDOM } = require("jsdom"); const { JSDOM } = require("jsdom");
const express = require("express"); const express = require("express");
const sinon = require("sinon");
const translations = require("../../translations/translations"); const translations = require("../../translations/translations");
/**
* Helper function to create a fresh Translator instance with DOM environment.
* @returns {object} Object containing window and Translator
*/
function createTranslationTestEnvironment () {
// Setup DOM environment with Translator
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "translator.js"), "utf-8");
const dom = new JSDOM("", { url: "http://localhost:3000", runScripts: "outside-only" });
dom.window.Log = { log: jest.fn(), error: jest.fn() };
dom.window.translations = translations;
dom.window.eval(translatorJs);
const window = dom.window;
return { window, Translator: window.Translator };
}
describe("translations", () => { describe("translations", () => {
let server; let server;
@@ -37,91 +54,76 @@ describe("translations", () => {
let dom; let dom;
beforeEach(() => { beforeEach(() => {
// Create a new JSDOM instance for each test // Create a new translation test environment for each test
dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" }); const env = createTranslationTestEnvironment();
const window = env.window;
// Mock the necessary global objects // Load class.js and module.js content directly for loadTranslations tests
dom.window.Log = { log: jest.fn(), error: jest.fn() };
dom.window.Translator = {};
dom.window.config = { language: "de" };
// Load class.js and module.js content directly
const classJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "class.js"), "utf-8"); const classJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "class.js"), "utf-8");
const moduleJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "module.js"), "utf-8"); const moduleJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "module.js"), "utf-8");
// Execute the scripts in the JSDOM context // Execute the scripts in the JSDOM context
dom.window.eval(classJs); window.eval(classJs);
dom.window.eval(moduleJs); window.eval(moduleJs);
// Additional setup for loadTranslations tests
window.config = { language: "de" };
dom = { window };
}); });
it("should load translation file", async () => { it("should load translation file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module, config } = dom.window; const { Translator, Module, config } = dom.window;
config.language = "en"; config.language = "en";
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null); Translator.load = jest.fn().mockImplementation((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations }); Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name"); const MMM = Module.create("name");
await MMM.loadTranslations(); await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1); expect(Translator.load.mock.calls).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", false)).toBe(true); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", false);
}); });
it("should load translation + fallback file", async () => { it("should load translation + fallback file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module } = dom.window; const { Translator, Module } = dom.window;
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null); Translator.load = jest.fn().mockImplementation((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations }); Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name"); const MMM = Module.create("name");
await MMM.loadTranslations(); await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(2); expect(Translator.load.mock.calls).toHaveLength(2);
expect(Translator.load.calledWith(MMM, "translations/de.json", false)).toBe(true); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/de.json", false);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true);
}); });
it("should load translation fallback file", async () => { it("should load translation fallback file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module, config } = dom.window; const { Translator, Module, config } = dom.window;
config.language = "--"; config.language = "--";
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null); Translator.load = jest.fn().mockImplementation((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations }); Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name"); const MMM = Module.create("name");
await MMM.loadTranslations(); await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1); expect(Translator.load.mock.calls).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true);
}); });
it("should load no file", async () => { it("should load no file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module } = dom.window; const { Translator, Module } = dom.window;
Translator.load = sinon.stub(); Translator.load = jest.fn();
Module.register("name", {}); Module.register("name", {});
const MMM = Module.create("name"); const MMM = Module.create("name");
await MMM.loadTranslations(); await MMM.loadTranslations();
expect(Translator.load.callCount).toBe(0); expect(Translator.load.mock.calls).toHaveLength(0);
}); });
}); });
@@ -132,21 +134,10 @@ describe("translations", () => {
} }
}; };
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "translator.js"), "utf-8");
describe("parsing language files through the Translator class", () => { describe("parsing language files through the Translator class", () => {
for (const language in translations) { for (const language in translations) {
it(`should parse ${language}`, async () => { it(`should parse ${language}`, async () => {
const dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" }); const { Translator } = createTranslationTestEnvironment();
dom.window.Log = { log: jest.fn() };
dom.window.translations = translations;
dom.window.eval(translatorJs);
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator } = dom.window;
await Translator.load(mmm, translations[language], false); await Translator.load(mmm, translations[language], false);
expect(typeof Translator.translations[mmm.name]).toBe("object"); expect(typeof Translator.translations[mmm.name]).toBe("object");
@@ -177,19 +168,10 @@ describe("translations", () => {
}; };
// Function to initialize JSDOM and load translations // Function to initialize JSDOM and load translations
const initializeTranslationDOM = (language) => { const initializeTranslationDOM = async (language) => {
const dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" }); const { Translator } = createTranslationTestEnvironment();
dom.window.Log = { log: jest.fn() }; await Translator.load(mmm, translations[language], false);
dom.window.translations = translations; return Translator.translations[mmm.name];
dom.window.eval(translatorJs);
return new Promise((resolve) => {
dom.window.onload = async () => {
const { Translator } = dom.window;
await Translator.load(mmm, translations[language], false);
resolve(Translator.translations[mmm.name]);
};
});
}; };
beforeAll(async () => { beforeAll(async () => {

View File

@@ -9,7 +9,7 @@ describe("Vendors", () => {
}); });
describe("Get list vendors", () => { describe("Get list vendors", () => {
const vendors = require(`${__dirname}/../../js/vendor.js`); const vendors = require(`${global.root_path}/js/vendor.js`);
Object.keys(vendors).forEach((vendor) => { Object.keys(vendors).forEach((vendor) => {
it(`should return 200 HTTP code for vendor "${vendor}"`, async () => { it(`should return 200 HTTP code for vendor "${vendor}"`, async () => {

View File

@@ -2,29 +2,38 @@ const helpers = require("../helpers/global-setup");
const weatherHelper = require("../helpers/weather-setup"); const weatherHelper = require("../helpers/weather-setup");
const { cleanupMockData } = require("../../utils/weather_mocker"); 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";
const SUNSET_DATE = "13 Jan 2019 12:30:00 GMT";
const SUN_EVENT_SELECTOR = ".weather .normal.medium span:nth-child(4)";
const EXPECTED_SUNRISE_TEXT = "7:00 am";
const EXPECTED_SUNSET_TEXT = "3:45 pm";
describe("Weather module", () => { describe("Weather module", () => {
afterEach(async () => { afterEach(async () => {
await helpers.stopApplication(); await helpers.stopApplication();
await cleanupMockData(); cleanupMockData();
}); });
describe("Current weather with sunrise", () => { describe("Current weather with sunrise", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherHelper.startApp("tests/configs/modules/weather/currentweather_default.js", "13 Jan 2019 00:30:00 GMT"); await weatherHelper.startApp(CURRENT_WEATHER_CONFIG, SUNRISE_DATE);
}); });
it("should render sunrise", async () => { it("should render sunrise", async () => {
await expect(weatherHelper.getText(".weather .normal.medium span:nth-child(4)", "7:00 am")).resolves.toBe(true); const isSunriseRendered = await weatherHelper.getText(SUN_EVENT_SELECTOR, EXPECTED_SUNRISE_TEXT);
expect(isSunriseRendered).toBe(true);
}); });
}); });
describe("Current weather with sunset", () => { describe("Current weather with sunset", () => {
beforeAll(async () => { beforeAll(async () => {
await weatherHelper.startApp("tests/configs/modules/weather/currentweather_default.js", "13 Jan 2019 12:30:00 GMT"); await weatherHelper.startApp(CURRENT_WEATHER_CONFIG, SUNSET_DATE);
}); });
it("should render sunset", async () => { it("should render sunset", async () => {
await expect(weatherHelper.getText(".weather .normal.medium span:nth-child(4)", "3:45 pm")).resolves.toBe(true); const isSunsetRendered = await weatherHelper.getText(SUN_EVENT_SELECTOR, EXPECTED_SUNSET_TEXT);
expect(isSunsetRendered).toBe(true);
}); });
}); });
}); });

View File

@@ -4,11 +4,23 @@ const helmet = require("helmet");
const { JSDOM } = require("jsdom"); const { JSDOM } = require("jsdom");
const express = require("express"); const express = require("express");
/**
* Helper function to create a fresh Translator instance with DOM environment.
* @returns {object} Object containing window and Translator
*/
function createTranslationTestEnvironment () {
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "..", "js", "translator.js"), "utf-8");
const dom = new JSDOM("", { url: "http://localhost:3001", runScripts: "outside-only" });
dom.window.Log = { log: jest.fn(), error: jest.fn() };
dom.window.eval(translatorJs);
return { window: dom.window, Translator: dom.window.Translator };
}
describe("Translator", () => { describe("Translator", () => {
let server; let server;
const sockets = new Set(); const sockets = new Set();
const translatorJsPath = path.join(__dirname, "..", "..", "..", "js", "translator.js");
const translatorJsScriptContent = fs.readFileSync(translatorJsPath, "utf8");
const translationTestData = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"), "utf8")); const translationTestData = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"), "utf8"));
beforeAll(() => { beforeAll(() => {
@@ -20,7 +32,7 @@ describe("Translator", () => {
}); });
app.use("/translations", express.static(path.join(__dirname, "..", "..", "..", "tests", "mocks"))); app.use("/translations", express.static(path.join(__dirname, "..", "..", "..", "tests", "mocks")));
server = app.listen(3000); server = app.listen(3001);
server.on("connection", (socket) => { server.on("connection", (socket) => {
sockets.add(socket); sockets.add(socket);
@@ -81,12 +93,7 @@ describe("Translator", () => {
}; };
it("should return custom module translation", async () => { it("should return custom module translation", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator); setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "Hello"); let translation = Translator.translate({ name: "MMM-Module" }, "Hello");
@@ -97,12 +104,7 @@ describe("Translator", () => {
}); });
it("should return core translation", async () => { it("should return core translation", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator); setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "FOO"); let translation = Translator.translate({ name: "MMM-Module" }, "FOO");
expect(translation).toBe("Foo"); expect(translation).toBe("Foo");
@@ -111,48 +113,28 @@ describe("Translator", () => {
}); });
it("should return custom module translation fallback", async () => { it("should return custom module translation fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator); setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "A key"); const translation = Translator.translate({ name: "MMM-Module" }, "A key");
expect(translation).toBe("A translation"); expect(translation).toBe("A translation");
}); });
it("should return core translation fallback", async () => { it("should return core translation fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator); setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Fallback"); const translation = Translator.translate({ name: "MMM-Module" }, "Fallback");
expect(translation).toBe("core 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", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator); setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}"); const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}");
expect(translation).toBe("Hallo {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", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator); setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "MISSING"); const translation = Translator.translate({ name: "MMM-Module" }, "MISSING");
expect(translation).toBe("MISSING"); expect(translation).toBe("MISSING");
@@ -163,17 +145,12 @@ describe("Translator", () => {
const mmm = { const mmm = {
name: "TranslationTest", name: "TranslationTest",
file (file) { file (file) {
return `http://localhost:3000/translations/${file}`; return `http://localhost:3001/translations/${file}`;
} }
}; };
it("should load translations", async () => { it("should load translations", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const file = "translation_test.json"; const file = "translation_test.json";
await Translator.load(mmm, file, false); await Translator.load(mmm, file, false);
@@ -182,30 +159,18 @@ describe("Translator", () => {
}); });
it("should load translation fallbacks", async () => { it("should load translation fallbacks", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const file = "translation_test.json"; const file = "translation_test.json";
dom.window.Log = { log: jest.fn() };
await Translator.load(mmm, file, true); await Translator.load(mmm, file, true);
const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8")); const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8"));
expect(Translator.translationsFallback[mmm.name]).toEqual(json); expect(Translator.translationsFallback[mmm.name]).toEqual(json);
}); });
it("should not load translations, if module fallback exists", async () => { it("should not load translations, if module fallback exists", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const file = "translation_test.json"; const file = "translation_test.json";
dom.window.Log = { log: jest.fn() };
Translator.translationsFallback[mmm.name] = { Translator.translationsFallback[mmm.name] = {
Hello: "Hallo" Hello: "Hallo"
}; };
@@ -220,37 +185,23 @@ describe("Translator", () => {
describe("loadCoreTranslations", () => { describe("loadCoreTranslations", () => {
it("should load core translations and fallback", async () => { it("should load core translations and fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { window, Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent); window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
dom.window.translations = { en: "http://localhost:3000/translations/translation_test.json" };
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
await Translator.loadCoreTranslations("en"); await Translator.loadCoreTranslations("en");
const en = translationTestData; const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslations).toEqual(en); expect(Translator.coreTranslations).toEqual(en);
expect(Translator.coreTranslationsFallback).toEqual(en); expect(Translator.coreTranslationsFallback).toEqual(en);
}); });
it("should load core fallback if language cannot be found", async () => { it("should load core fallback if language cannot be found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { window, Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent); window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
dom.window.translations = { en: "http://localhost:3000/translations/translation_test.json" };
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
await Translator.loadCoreTranslations("MISSINGLANG"); await Translator.loadCoreTranslations("MISSINGLANG");
const en = translationTestData; const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslations).toEqual({}); expect(Translator.coreTranslations).toEqual({});
expect(Translator.coreTranslationsFallback).toEqual(en); expect(Translator.coreTranslationsFallback).toEqual(en);
}); });
@@ -258,35 +209,20 @@ describe("Translator", () => {
describe("loadCoreTranslationsFallback", () => { describe("loadCoreTranslationsFallback", () => {
it("should load core translations fallback", async () => { it("should load core translations fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { window, Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent); window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
dom.window.translations = { en: "http://localhost:3000/translations/translation_test.json" };
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
await Translator.loadCoreTranslationsFallback(); await Translator.loadCoreTranslationsFallback();
const en = translationTestData; const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslationsFallback).toEqual(en); expect(Translator.coreTranslationsFallback).toEqual(en);
}); });
it("should load core fallback if language cannot be found", async () => { it("should load core fallback if language cannot be found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" }); const { window, Translator } = createTranslationTestEnvironment();
dom.window.eval(translatorJsScriptContent); window.translations = {};
dom.window.translations = {};
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
await Translator.loadCoreTranslations(); await Translator.loadCoreTranslations();
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslationsFallback).toEqual({}); expect(Translator.coreTranslationsFallback).toEqual({});
}); });
}); });

View File

@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Updatenotification custom module returns status information without hash 1`] = ` exports[`Updatenotification custom module returns status information without hash 1`] = `
{ {

View File

@@ -1,4 +1,5 @@
const { cors } = require("../../../js/server_functions"); const { expect } = require("playwright/test");
const { cors, getUserAgent } = require("#server_functions");
describe("server_functions tests", () => { describe("server_functions tests", () => {
describe("The cors method", () => { describe("The cors method", () => {
@@ -142,5 +143,21 @@ describe("server_functions tests", () => {
expect(corsResponse.set.mock.calls[2][0]).toBe("header2"); expect(corsResponse.set.mock.calls[2][0]).toBe("header2");
expect(corsResponse.set.mock.calls[2][1]).toBe("value2"); expect(corsResponse.set.mock.calls[2][1]).toBe("value2");
}); });
it("Gets User-Agent from configuration", async () => {
config = {};
let userAgent;
userAgent = getUserAgent();
expect(userAgent).toContain("Mozilla/5.0 (Node.js ");
config.userAgent = "Mozilla/5.0 (Foo)";
userAgent = getUserAgent();
expect(userAgent).toBe("Mozilla/5.0 (Foo)");
config.userAgent = () => "Mozilla/5.0 (Bar)";
userAgent = getUserAgent();
expect(userAgent).toBe("Mozilla/5.0 (Bar)");
});
}); });
}); });

View File

@@ -4,7 +4,7 @@ const path = require("node:path");
const root_path = path.join(__dirname, "../../.."); const root_path = path.join(__dirname, "../../..");
describe("Default modules set in modules/default/defaultmodules.js", () => { describe("Default modules set in modules/default/defaultmodules.js", () => {
const expectedDefaultModules = require("../../../modules/default/defaultmodules"); const expectedDefaultModules = require(`${root_path}/modules/default/defaultmodules`);
for (const defaultModule of expectedDefaultModules) { for (const defaultModule of expectedDefaultModules) {
it(`contains a folder for modules/default/${defaultModule}"`, () => { it(`contains a folder for modules/default/${defaultModule}"`, () => {

View File

@@ -2,7 +2,7 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const root_path = path.join(__dirname, "../../.."); const root_path = path.join(__dirname, "../../..");
const version = require(`${__dirname}/../../../package.json`).version; const version = require(`${root_path}/package.json`).version;
describe("'global.root_path' set in js/app.js", () => { describe("'global.root_path' set in js/app.js", () => {
const expectedSubPaths = ["modules", "serveronly", "js", "js/app.js", "js/main.js", "js/electron.js", "config"]; const expectedSubPaths = ["modules", "serveronly", "js", "js/app.js", "js/main.js", "js/electron.js", "config"];

View File

@@ -10,7 +10,7 @@ describe("Calendar fetcher utils test", () => {
excludedEvents: [], excludedEvents: [],
includePastEvents: false, includePastEvents: false,
maximumEntries: 10, maximumEntries: 10,
maximumNumberOfDays: 365 maximumNumberOfDays: 367
}; };
describe("filterEvents", () => { describe("filterEvents", () => {

View File

@@ -2,10 +2,18 @@ const weather = require("../../../../../modules/default/weather/weatherutils");
const WeatherUtils = require("../../../../../modules/default/weather/weatherutils"); const WeatherUtils = require("../../../../../modules/default/weather/weatherutils");
describe("Weather utils tests", () => { describe("Weather utils tests", () => {
describe("windspeed conversion to imperial", () => { describe("temperature conversion to imperial", () => {
it("should convert temp correctly from Celsius to Celsius", () => {
expect(Math.round(WeatherUtils.convertTemp(10, "metric"))).toBe(10);
});
it("should convert temp correctly from Celsius to Fahrenheit", () => { it("should convert temp correctly from Celsius to Fahrenheit", () => {
expect(Math.round(WeatherUtils.convertTemp(10, "imperial"))).toBe(50); expect(Math.round(WeatherUtils.convertTemp(10, "imperial"))).toBe(50);
}); });
it("should convert temp correctly from Fahrenheit to Celsius", () => {
expect(Math.round(WeatherUtils.convertTempToMetric(10))).toBe(-12);
});
}); });
describe("windspeed conversion to beaufort", () => { describe("windspeed conversion to beaufort", () => {
@@ -44,11 +52,11 @@ describe("Weather utils tests", () => {
describe("feelsLike calculation", () => { describe("feelsLike calculation", () => {
it("should return a calculated feelsLike info (negative value)", () => { it("should return a calculated feelsLike info (negative value)", () => {
expect(WeatherUtils.calculateFeelsLike(0, 20, 40)).toBe(-9.444444444444445); expect(WeatherUtils.calculateFeelsLike(0, 20, 40)).toBe(-9.397005931555448);
}); });
it("should return a calculated feelsLike info (positive value)", () => { it("should return a calculated feelsLike info (positive value)", () => {
expect(WeatherUtils.calculateFeelsLike(30, 0, 60)).toBe(32.8320322777777); expect(WeatherUtils.calculateFeelsLike(30, 0, 60)).toBe(32.832032277777756);
}); });
}); });

View File

@@ -1,7 +1,6 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const util = require("node:util"); const exec = require("node:child_process").execSync;
const exec = util.promisify(require("node:child_process").exec);
/** /**
* @param {string} type what data to read, can be "current" "forecast" or "hourly * @param {string} type what data to read, can be "current" "forecast" or "hourly
@@ -45,9 +44,9 @@ const injectMockData = (configFileName, extendedData = {}) => {
return tempFile; return tempFile;
}; };
const cleanupMockData = async () => { const cleanupMockData = () => {
const tempDir = path.resolve(`${__dirname}/../configs`).toString(); const tempDir = path.resolve(`${__dirname}/../configs`).toString();
await exec(`find ${tempDir} -type f -name *_temp.js -delete`); exec(`find ${tempDir} -type f -name *_temp.js -delete`);
}; };
module.exports = { injectMockData, cleanupMockData }; module.exports = { injectMockData, cleanupMockData };