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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
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."
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².
## [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
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
- 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)
- electron-rebuild test suite for electron and 3rd party modules compatibility (#3392)
- 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 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
- Update layout of current weather indoor values
- [weather] Update layout of current weather indoor values
### 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 tests for serveronly
- 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] Allow force scanning with `SCAN_UPDATES` notification from other modules
- Added per-calendar fetchInterval
@@ -687,7 +740,7 @@ Special thanks to the following contributors: @AmpioRosso, @eouia, @fewieden, @j
### Fixed
- 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 incorrect time zone correction of recurring full day events (#2632 and #2634).
- 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.
- Updated jsdocs and print warnings during testing too.
- 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).
- Use of `logger.js` in jest tests.
- 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)
[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.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

View File

@@ -35,7 +35,7 @@ Are done by
- [ ] test `prep-release` branch
- [ ] update `CHANGELOG.md`
- [ ] 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
- [ ] commit and push all changes
- [ ] create pull request from `prep-release` to `develop` branch with title `Prepare Release 2.xx.0`

View File

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

View File

@@ -252,3 +252,15 @@ sup {
.region .container.hidden {
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 jest from "eslint-plugin-jest";
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 stylistic from "@stylistic/eslint-plugin";
@@ -23,8 +23,8 @@ export default defineConfig([
moment: "readonly"
}
},
plugins: {js, jsdoc, stylistic},
extends: [importX.recommended, jest.configs["flat/recommended"], "js/recommended", jsdoc.configs["flat/recommended"], "stylistic/all"],
plugins: {js, stylistic},
extends: [importX.recommended, jest.configs["flat/recommended"], "js/recommended", jsdocPlugin.configs["flat/recommended"], "stylistic/all"],
rules: {
"@stylistic/array-element-newline": ["error", "consistent"],
"@stylistic/arrow-parens": ["error", "always"],
@@ -88,7 +88,6 @@ export default defineConfig([
files: ["**/*.js"],
ignores: [
"clientonly/index.js",
"modules/default/calendar/debug.js",
"js/logger.js",
"tests/**/*.js"
],

View File

@@ -1,5 +1,4 @@
module.exports = async () => {
return {
const config = {
verbose: true,
testTimeout: 20000,
testSequencer: "<rootDir>/tests/utils/test_sequencer.js",
@@ -25,8 +24,14 @@ module.exports = async () => {
testPathIgnorePatterns: ["<rootDir>/tests/e2e/helpers", "<rootDir>/tests/e2e/mocks"]
}
],
collectCoverageFrom: ["./clientonly/**/*.js", "./js/**/*.js", "./modules/default/**/*.js", "./serveronly/**/*.js"],
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 Log = require("logger");
// global absolute root path
global.root_path = path.resolve(`${__dirname}/../`);
const Server = require(`${__dirname}/server`);
const Utils = require(`${__dirname}/utils`);
const defaultModules = require(`${__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
const { setGlobalDispatcher, Agent } = require("undici");
const { getEnvVarsAsObj } = require("#server_functions");
// common timeout value, provide environment override in case
const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;
// 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";
Log.log(`Starting MagicMirror: v${global.version}`);
// Log system information.
Utils.logSystemInformation();
// global absolute root path
global.root_path = path.resolve(`${__dirname}/../`);
Utils.logSystemInformation(global.version);
if (process.env.MM_CONFIG_FILE) {
global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, "");
@@ -181,10 +181,10 @@ function App () {
const elements = module.split("/");
const moduleName = elements[elements.length - 1];
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)) {
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) {
moduleFolder = defaultModuleFolder;
} else {

View File

@@ -90,7 +90,7 @@ const MM = (function () {
/**
* Send a notification to all modules.
* @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} [sendTo] The (optional) module to send the notification to.
*/
@@ -262,7 +262,7 @@ const MM = (function () {
* Hide the module.
* @param {Module} module The module to hide.
* @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.
*/
const hideModule = function (module, speed, callback, options = {}) {
@@ -347,7 +347,7 @@ const MM = (function () {
* Show the module.
* @param {Module} module The module to show.
* @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.
*/
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.
* @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) {
modules.map(function (module) {
@@ -629,7 +629,7 @@ const MM = (function () {
/**
* Send a notification to all modules.
* @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.
*/
sendNotification (notification, payload, sender) {
@@ -688,7 +688,7 @@ const MM = (function () {
* Hide the module.
* @param {Module} module The module to hide.
* @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.
*/
hideModule (module, speed, callback, options) {
@@ -700,7 +700,7 @@ const MM = (function () {
* Show the module.
* @param {Module} module The module to show.
* @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.
*/
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.
*
* return Map<String, String> -
* @returns {*} A map with langKeys and filenames.
* @returns {Map} A map with langKeys and filenames.
*/
getTranslations () {
return false;
@@ -140,7 +140,7 @@ const Module = Class.extend({
/**
* Called by the MagicMirror² core when a notification arrives.
* @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.
*/
notificationReceived (notification, payload, sender) {
@@ -176,7 +176,7 @@ const Module = Class.extend({
/**
* Called when a socket notification arrives.
* @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) {
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.
* @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) {
MM.sendNotification(notification, payload, this);
@@ -353,7 +353,7 @@ const Module = Class.extend({
/**
* Send a socket notification to the node helper.
* @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) {
this.socket().sendNotification(notification, payload);
@@ -362,7 +362,7 @@ const Module = Class.extend({
/**
* Hide this module.
* @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.
*/
hide (speed, callback, options = {}) {
@@ -389,7 +389,7 @@ const Module = Class.extend({
/**
* Show this module.
* @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.
*/
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.
* @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) {
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 socketio = require("socket.io");
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`);
@@ -42,7 +42,9 @@ function Server (config) {
origin: /.*$/,
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) => {
@@ -53,6 +55,29 @@ function Server (config) {
});
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");
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.
*/
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);
if (headersToSendMatch) {
const headers = headersToSendMatch[1].split(",");
@@ -129,6 +129,27 @@ function getVersion (req, res) {
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.
* @returns {object} environment variables key: values
@@ -155,4 +176,4 @@ function getEnvVars (req, res) {
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;
}
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 () {};

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
{% if imageUrl or imageFA %}
{% set imageHeight = imageHeight if imageHeight else "80px" %}
{% if imageUrl %}
<img src="{{ imageUrl }}"
height="{{ imageHeight }}"
style="margin-bottom: 10px" />
<img src="{{ imageUrl }}" height="{{ imageHeight }}" style="margin-bottom: 10px" />
{% else %}
<span class="bright fas fa-{{ imageFA }}"
<span
class="bright fas fa-{{ imageFA }}"
style="margin-bottom: 10px;
font-size: {{ imageHeight }}"></span>
font-size: {{ imageHeight }}"
></span>
{% endif %}
<br />
{% endif %}

View File

@@ -2,23 +2,14 @@
display: flex;
flex-direction: row;
justify-content: flex-end;
padding-left: 0;
padding-right: 10px;
font-size: var(--font-size-small);
}
.calendar .symbol span {
padding-top: 4px;
gap: 5px;
}
.calendar .title {
padding-left: 0;
padding-right: 0;
vertical-align: top;
padding: 0 10px;
}
.calendar .time {
padding-left: 30px;
padding-left: 20px;
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
pastDaysCount: 0,
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-",
showLocation: false,
displayRepeatingCountTitle: false,
@@ -168,8 +168,8 @@ Module.register("calendar", {
this.selfUpdate();
},
notificationReceived (notification, payload, sender) {
notificationReceived (notification, payload, sender) {
if (notification === "FETCH_CALENDAR") {
if (this.hasCalendarURL(payload.url)) {
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
@@ -217,7 +217,6 @@ Module.register("calendar", {
// Override dom generator.
getDom () {
const ONE_SECOND = 1000; // 1,000 milliseconds
const events = this.createEventList(true);
const wrapper = document.createElement("table");
wrapper.className = this.config.tableClass;
@@ -308,15 +307,12 @@ Module.register("calendar", {
}
const symbolClass = this.symbolClassForUrl(event.url);
symbolWrapper.className = `symbol align-right ${symbolClass}`;
symbolWrapper.className = `symbol ${symbolClass}`;
const symbols = this.symbolsForEvent(event);
symbols.forEach((s, index) => {
symbols.forEach((s) => {
const symbol = document.createElement("span");
symbol.className = s;
if (index > 0) {
symbol.style.paddingLeft = "5px";
}
symbolWrapper.appendChild(symbol);
});
eventWrapper.appendChild(symbolWrapper);
@@ -601,7 +597,6 @@ Module.register("calendar", {
*/
createEventList (limitNumberOfEntries) {
let now = moment();
let today = now.clone().startOf("day");
let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days");
let events = [];
@@ -705,30 +700,24 @@ Module.register("calendar", {
* Limit the number of days displayed
* If limitDays is set > 0, limit display to that number of days
*/
if (this.config.limitDays > 0) {
let newEvents = [];
let lastDate = today.clone().subtract(1, "days");
let days = 0;
for (const ev of events) {
let eventDate = this.timestampToMoment(ev.startDate);
if (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper
// Group all events by date, events on the same date will be in a list with the key being the date.
const eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format("YYYY-MM-DD"));
const newEvents = [];
let currentDate = moment();
let daysCollected = 0;
/*
* if date of event is later than lastdate
* check if we already are showing max unique days
*/
if (eventDate.isAfter(lastDate)) {
// if the only entry in the first day is a full day event that day is not counted as unique
if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) {
days--;
while (daysCollected < this.config.limitDays) {
const dateStr = currentDate.format("YYYY-MM-DD");
// Check if there are events on the currentDate
if (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) {
// 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.
newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment())));
// Since we found a day with events, increase the daysCollected by 1
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;
}
@@ -887,7 +876,7 @@ Module.register("calendar", {
* @param {string} url The calendar url
* @param {string} property The property to look for
* @param {string} defaultValue The value if the property is not found
* @returns {*} The property
* @returns {property} The property
*/
getCalendarProperty (url, property, defaultValue) {
for (const calendar of this.config.calendars) {

View File

@@ -3,6 +3,8 @@ const ical = require("node-ical");
const Log = require("logger");
const NodeHelper = require("node_helper");
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 = () => {
clearTimeout(reloadTimer);
reloadTimer = null;
const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
let httpsAgent = null;
let headers = {
"User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`
"User-Agent": getUserAgent()
};
if (selfSignedCert) {
@@ -65,31 +66,18 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
});
} catch (error) {
fetchFailedCallback(this, error);
scheduleTimer();
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
return;
}
this.broadcastEvents();
scheduleTimer();
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
})
.catch((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 */
/**
@@ -109,7 +97,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
/**
* Sets the on success callback
* @param {Function} callback The on success callback.
* @param {eventsReceivedCallback} callback The on success callback.
*/
this.onReceive = function (callback) {
eventsReceivedCallback = callback;
@@ -117,7 +105,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
/**
* Sets the on error callback
* @param {Function} callback The on error callback.
* @param {fetchFailedCallback} callback The on error callback.
*/
this.onError = function (callback) {
fetchFailedCallback = callback;

View File

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

View File

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

View File

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

View File

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

View File

@@ -181,7 +181,7 @@ Module.register("newsfeed", {
* Gets a feed property by name
* @param {object} feed A feed object.
* @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) {
let res = this.config[property];

View File

@@ -8,19 +8,25 @@
{% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %}
{% if dangerouslyDisableAutoEscaping %}
{% if showTitleAsUrl %}
<a href="{{ url }}"
<a
href="{{ url }}"
style="text-decoration:none;
color:#ffffff"
target="_blank">{{ title | safe }}</a>
target="_blank"
>{{ title | safe }}</a
>
{% else %}
{{ title | safe }}
{% endif %}
{% else %}
{% if showTitleAsUrl %}
<a href="{{ url }}"
<a
href="{{ url }}"
style="text-decoration:none;
color:#ffffff"
target="_blank">{{ title }}</a>
target="_blank"
>{{ title }}</a
>
{% else %}
{{ title }}
{% endif %}
@@ -34,14 +40,12 @@
{% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}
<div class="newsfeed-source light small dimmed">
{% if item.sourceTitle and config.showSourceTitle %}
{{ item.sourceTitle }}{% if config.showPublishDate %}, {% else %}:{% endif %}
{{ item.sourceTitle }}{% if config.showPublishDate %},{% else %}:{% endif %}
{% 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>
<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 %}
@@ -59,14 +63,12 @@
{% 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 %}
{{ 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 class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">{{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}</div>
{% if config.showDescription %}
<div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
{% if config.truncDescription %}
@@ -78,12 +80,10 @@
{% endif %}
</div>
{% endif %}
{% elseif empty %}
{% 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>
{% elseif error %}
<div class="small dimmed">{{ "MODULE_CONFIG_ERROR" | translate({MODULE_NAME: "Newsfeed", ERROR: error}) | safe }}</div>
{% else %}
<div class="small dimmed">{{ "LOADING" | translate | safe }}</div>
{% endif %}

View File

@@ -5,6 +5,8 @@ const iconv = require("iconv-lite");
const { htmlToText } = require("html-to-text");
const Log = require("logger");
const NodeHelper = require("node_helper");
const { getUserAgent } = require("#server_functions");
const { scheduleTimer } = require("#module_functions");
/**
* Responsible for requesting an update on the set interval and broadcasting the data.
@@ -79,12 +81,12 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
parser.on("error", (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
parser.on("finish", () => {
scheduleTimer();
scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);
});
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 = {
"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",
Pragma: "no-cache"
};
@@ -120,23 +121,10 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
})
.catch((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 */
/**

View File

@@ -1,3 +1 @@
<div class="small bright">
{{ "MODULE_CONFIG_CHANGED" | translate({MODULE_NAME: "Newsfeed"}) | safe }}
</div>
<div class="small bright">{{ "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 Log = require("logger");
const BASE_DIR = path.normalize(`${__dirname}/../../../`);
class GitHelper {
constructor () {
this.gitRepos = [];
@@ -35,10 +33,10 @@ class GitHelper {
}
async add (moduleName) {
let moduleFolder = BASE_DIR;
let moduleFolder = `${global.root_path}`;
if (moduleName !== "MagicMirror") {
moduleFolder = `${moduleFolder}modules/${moduleName}`;
moduleFolder = `${moduleFolder}/modules/${moduleName}`;
}
try {

View File

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

View File

@@ -1,6 +1,8 @@
{% macro 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"
><span>{{ current.humidity | decimalSymbol }}</span><sup>&nbsp;<i class="wi wi-humidity humidity-icon"></i></sup
></span>
{% endif %}
{% endmacro %}
{% if current %}
@@ -41,27 +43,25 @@
{% endif %}
</div>
{% endif %}
<div class="large">
<div class="flex large type-temp">
{% 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>
<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>
<span> {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} </span>
</sub>
{% endif %}
</span>
{% endif %}
{% if current.weatherType %}
<span class="light wi weathericon wi-{{ current.weatherType }}"></span>
{% endif %}
<span class="light bright">{{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}</span>
{% if config.showHumidity === "temp" %}
<span class="medium bright">{{ humidity() }}</span>
@@ -79,15 +79,11 @@
<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>
<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>
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}% </span>
{% endif %}
</div>
{% endif %}
@@ -98,4 +94,4 @@
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %}
<!-- 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

@@ -7,8 +7,10 @@
{% 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 %}>
<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 %}
@@ -19,21 +21,13 @@
<td class="bright weather-icon">
<span class="wi weathericon wi-{{ f.weatherType }}"></span>
</td>
<td class="align-right bright max-temp">
{{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}
</td>
<td class="align-right min-temp">
{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}
</td>
<td class="align-right bright max-temp">{{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
<td class="align-right min-temp">{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
{% if config.showPrecipitationAmount %}
<td class="align-right bright precipitation-amount">
{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}
</td>
<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>
<td class="align-right bright precipitation-prob">{{ f.precipitationProbability | unit('precip', '%') }}</td>
{% endif %}
{% if config.showUVIndex %}
<td class="align-right dimmed uv-index">
@@ -49,4 +43,4 @@
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %}
<!-- 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

@@ -4,15 +4,15 @@
<table class="{{ config.tableClass }}">
{% set hours = hourly.slice(0, numSteps) %}
{% for hour in hours %}
<tr {% if config.colored %}class="colored"{% endif %}
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}>
<tr
{% if config.colored %}class="colored"{% endif %}
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}
>
<td class="day">{{ hour.date | formatTime }}</td>
<td class="bright weather-icon">
<span class="wi weathericon wi-{{ hour.weatherType }}"></span>
</td>
<td class="align-right bright">
{{ hour.temperature | roundValue | unit("temperature") }}
</td>
<td class="align-right bright">{{ hour.temperature | roundValue | unit("temperature") }}</td>
{% if config.showUVIndex %}
<td class="align-right bright uv-index">
{% if hour.uv_index!=0 %}
@@ -29,16 +29,12 @@
{% endif %}
{% if config.showPrecipitationAmount %}
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
<td class="align-right bright precipitation-amount">
{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}
</td>
<td class="align-right bright precipitation-amount">{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}</td>
{% endif %}
{% endif %}
{% if config.showPrecipitationProbability %}
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
<td class="align-right bright precipitation-prob">
{{ hour.precipitationProbability | unit('precip', '%') }}
</td>
<td class="align-right bright precipitation-prob">{{ hour.precipitationProbability | unit('precip', '%') }}</td>
{% endif %}
{% endif %}
</tr>
@@ -49,4 +45,4 @@
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %}
<!-- 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
* column A (Codes) and provCode under column C (Province).
*
* Acknowledgement: Some logic and code for parsing Environment Canada web pages is based on material from MMM-EnvCanada
*
* License to use Environment Canada (EC) data is detailed here:
* https://eccc-msc.github.io/open-data/licence/readme_en/
*/
@@ -49,6 +51,9 @@ WeatherProvider.register("envcanada", {
this.todayTempCacheMax = 0;
this.todayCached = false;
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
*/
fetchCurrentWeather () {
this.fetchData(this.getUrl(), "xml")
.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());
this.fetchCommon("Current");
},
/*
* 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 () {
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
return;
}
const forecastWeather = this.generateWeatherObjectsFromForecast(data);
this.setWeatherForecast(forecastWeather);
})
.catch(function (request) {
Log.error("Could not load EnvCanada forecast data ... ", request);
})
.finally(() => this.updateAvailable());
this.fetchCommon("Forecast");
},
/*
* 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 () {
this.fetchData(this.getUrl(), "xml")
.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());
this.fetchCommon("Hourly");
},
/*
* Build the EC URL based on the Site Code and Province Code specified in the config params. Note that the
* URL defaults to the English version simply because there is no language dependency in the data
* being accessed. This is only pertinent when using the EC data elements that contain a textual forecast.
* Because the process to fetch weather data is virtually the same for Current, Forecast/Daily, and Hourly weather,
* a common module is used to access the EC weather data. The only customization (based on the caller of this routine)
* is how the data will be parsed to satisfy the Weather module config in Config.js
*
* Accessing EC weather data is accomplished in 2 steps:
*
* 1. Query the MSC Datamart Index page, which returns a list of all the filenames for all the cities that have
* weather data currently available.
*
* 2. With the city filename identified, build the appropriate URL and get the weather data (XML document) for the
* city specified in the Weather module Config information
*/
fetchCommon (target) {
const forecastURL = this.getUrl(); // Get the 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 () {
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.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").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
* 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'.
*
@@ -225,7 +318,7 @@ WeatherProvider.register("envcanada", {
*
* But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled
* off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in
* Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, 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
* 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.
*
* 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
* ignore any nightime 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
* ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show
* the nighttime forecast after a certain point in the afternoon. As such, we will be showing the nighttime POP
* (if one exists) in that specific scenario.
*
* Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what
* people are interested in seeing. While EC provides a separate accumulation for daytime and 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
* ignore any nightime 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.
* ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show
* the nighttime forecast after a certain point in that specific scenario.
*/
setPrecipitation (weather, foreGroup, today) {
if (foreGroup[today].querySelector("precipitation accumulation")) {

View File

@@ -13,7 +13,7 @@ WeatherProvider.register("openmeteo", {
/*
* Set the name of the provider.
* Not strictly required, but helps for debugging.
* Not strictly required but helps for debugging.
*/
providerName: "Open-Meteo",
@@ -348,7 +348,7 @@ WeatherProvider.register("openmeteo", {
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.
* 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.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime());
currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m);
currentWeather.feelsLikeTemp = parseFloat(weather.hourly[h].apparent_temperature);
currentWeather.rain = parseFloat(weather.hourly[h].rain);
currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10);
currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation);

View File

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

View File

@@ -218,7 +218,7 @@ WeatherProvider.register("weathergov", {
currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value;
currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.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) {
currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value;
} else if (currentWeatherData.windChill.value !== null) {

View File

@@ -1,9 +1,6 @@
.weather .weathericon,
.weather .fa-home {
font-size: 75%;
line-height: 65px;
display: inline-block;
transform: translate(0, -3px);
}
.weather .humidity-icon {
@@ -37,10 +34,6 @@
padding-right: 0;
}
.weather tr .weathericon {
line-height: 25px;
}
.weather tr.colored .min-temp {
color: #bcddff;
}
@@ -48,3 +41,9 @@
.weather tr.colored .max-temp {
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?
updateAvailable () {
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();
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 = {

View File

@@ -53,7 +53,7 @@ const WeatherUtils = {
/**
* Convert temp (from degrees C) into imperial or metric unit depending on
* 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'
* @returns {number} the converted temperature
*/
@@ -61,6 +61,15 @@ const WeatherUtils = {
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.
* @param {number} windInMS the windspeed in meter/sec you want to convert
@@ -118,27 +127,51 @@ const WeatherUtils = {
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) {
const windInMph = this.convertWind(windSpeed, "imperial");
const tempInF = this.convertTemp(temperature, "imperial");
let feelsLike = tempInF;
if (windInMph > 3 && tempInF < 50) {
feelsLike = Math.round(35.74 + 0.6215 * tempInF - 35.75 * Math.pow(windInMph, 0.16) + 0.4275 * tempInF * Math.pow(windInMph, 0.16));
} else if (tempInF > 80 && humidity > 40) {
feelsLike
= -42.379
+ 2.04901523 * tempInF
+ 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;
let HI;
let WC = tempInF;
// Calculate wind chill for certain conditions
if (tempInF <= 70 && windInMph >= 3) {
WC = 35.74 + (0.6215 * tempInF) - 35.75 * Math.pow(windInMph, 0.16) + ((0.4275 * tempInF) * Math.pow(windInMph, 0.16));
}
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);
},
/**

3365
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,10 +1,17 @@
const config = {
plugins: ["prettier-plugin-jinja-template"],
overrides: [
{
files: "*.md",
options: {
parser: "markdown"
}
},
{
files: ["*.njk"],
options: {
parser: "jinja-template"
}
}
],
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");
// 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", () => {
// define config file for testing
let testConfigFile = "tests/configs/modules/compliments/compliments_animateCSS.js";
// define config file to fallback to default: wrong animation name (must return no animation)
let testConfigFileFallbackToDefault = "tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js";
// define config file with an inverted name animation : in for out and vice versa (must return no animation)
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";
// Config variants under test
const TEST_CONFIG_ANIM = "tests/configs/modules/compliments/compliments_animateCSS.js";
const TEST_CONFIG_FALLBACK = "tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js"; // invalid animation names
const TEST_CONFIG_INVERTED = "tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js"; // in/out swapped
const TEST_CONFIG_NONE = "tests/configs/modules/compliments/compliments_anytime.js"; // no animations defined
/**
* move similar tests in function doTest
* @param {string} [animationIn] animation in name of AnimateCSS to test.
* @param {string} [animationOut] animation out name of AnimateCSS to test.
* @returns {boolean} result
* Get the compliments container element (waits until available).
* @returns {Promise<HTMLElement>} compliments root element
*/
const doTest = async (animationIn, animationOut) => {
async function getComplimentsElement () {
await helpers.getDocument();
let elem = await helpers.waitForElement(".compliments");
expect(elem).not.toBeNull();
let styles = window.getComputedStyle(elem);
if (animationIn && animationIn !== "") {
expect(styles._values.get("animation-name")).toBe(animationIn);
} else {
expect(styles._values.get("animation-name")).toBeUndefined();
const el = await helpers.waitForElement(".compliments");
expect(el).not.toBeNull();
return el;
}
if (animationOut && animationOut !== "") {
elem = await helpers.waitForElement(`.compliments.animate__animated.animate__${animationOut}`);
expect(elem).not.toBeNull();
styles = window.getComputedStyle(elem);
expect(styles._values.get("animation-name")).toBe(animationOut);
} else {
expect(styles._values.get("animation-name")).toBeUndefined();
/**
* Wait for an Animate.css class to appear and persist briefly.
* @param {string} cls Animation class name without leading dot (e.g. animate__flipInX)
* @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}`);
}
/**
* Assert that no Animate.css animation class is applied within a time window.
* @param {number} [ms] Observation period in ms (default 2000)
* @returns {Promise<void>}
*/
async function assertNoAnimationWithin (ms = 2000) {
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;
};
}
afterEach(async () => {
await helpers.stopApplication();
@@ -45,29 +81,29 @@ describe("AnimateCSS integration Test", () => {
describe("animateIn and animateOut Test", () => {
it("with flipInX and flipOutX animation", async () => {
await helpers.startApplication(testConfigFile);
await expect(doTest("flipInX", "flipOutX")).resolves.toBe(true);
await helpers.startApplication(TEST_CONFIG_ANIM);
await expect(runAnimationTest("flipInX", "flipOutX")).resolves.toBe(true);
});
});
describe("use animateOut name for animateIn (vice versa) Test", () => {
it("without animation", async () => {
await helpers.startApplication(testConfigFileInvertedAnimationName);
await expect(doTest()).resolves.toBe(true);
it("without animation (inverted names)", async () => {
await helpers.startApplication(TEST_CONFIG_INVERTED);
await expect(runAnimationTest()).resolves.toBe(true);
});
});
describe("false Animation name test", () => {
it("without animation", async () => {
await helpers.startApplication(testConfigFileFallbackToDefault);
await expect(doTest()).resolves.toBe(true);
it("without animation (invalid names)", async () => {
await helpers.startApplication(TEST_CONFIG_FALLBACK);
await expect(runAnimationTest()).resolves.toBe(true);
});
});
describe("no Animation defined test", () => {
it("without animation", async () => {
await helpers.startApplication(testConfigByDefault);
await expect(doTest()).resolves.toBe(true);
it("without animation (no config)", async () => {
await helpers.startApplication(TEST_CONFIG_NONE);
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", () => {
const fontFiles = [];
// 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;
let match = regex.exec(fileContent);
while (match !== null) {

View File

@@ -13,10 +13,9 @@ app.use(basicAuth);
// Set available directories
const directories = ["/tests/configs", "/tests/mocks"];
const rootPath = path.resolve(`${__dirname}/../../../`);
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;

View File

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

View File

@@ -1,17 +1,52 @@
const helpers = require("../helpers/global-setup");
describe("Alert module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/default.js");
await helpers.getDocument();
});
afterAll(async () => {
await helpers.stopApplication();
});
it("should show the welcome message", async () => {
describe("with welcome_message set to false", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/welcome_false.js");
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 () => {
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", () => {
beforeAll(() => {
const baseDir = `${__dirname}/../../..`;
fs.cpSync(`${baseDir}/modules/default/newsfeed`, `${baseDir}/config/newsfeed`, { recursive: true });
fs.cpSync(`${global.root_path}/modules/default/newsfeed`, `${global.root_path}/config/newsfeed`, { recursive: true });
process.env.MM_MODULES_DIR = "config";
});

View File

@@ -1,17 +1,15 @@
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
const { cleanupMockData } = require("../../utils/weather_mocker");
describe("Weather module", () => {
afterAll(async () => {
await helpers.stopApplication();
await cleanupMockData();
await weatherFunc.stopApplication();
});
describe("Current weather", () => {
describe("Default configuration", () => {
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 () => {
@@ -20,12 +18,16 @@ describe("Weather module", () => {
it("should render temperature with icon", async () => {
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 () => {
// 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);
});
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);
});
@@ -34,7 +36,7 @@ describe("Weather module", () => {
describe("Compliments Integration", () => {
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 () => {
@@ -44,7 +46,7 @@ describe("Weather module", () => {
describe("Configuration Options", () => {
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 () => {
@@ -72,7 +74,7 @@ describe("Weather module", () => {
describe("Current weather with imperial units", () => {
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 () => {

View File

@@ -1,16 +1,14 @@
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
const { cleanupMockData } = require("../../utils/weather_mocker");
describe("Weather module: Weather Forecast", () => {
afterAll(async () => {
await helpers.stopApplication();
await cleanupMockData();
await weatherFunc.stopApplication();
});
describe("Default configuration", () => {
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"];
@@ -54,7 +52,7 @@ describe("Weather module: Weather Forecast", () => {
describe("Absolute configuration", () => {
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"];
@@ -67,7 +65,7 @@ describe("Weather module: Weather Forecast", () => {
describe("Configuration Options", () => {
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 () => {
@@ -94,7 +92,7 @@ describe("Weather module: Weather Forecast", () => {
describe("Forecast weather with imperial units", () => {
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", () => {

View File

@@ -1,16 +1,13 @@
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
const { cleanupMockData } = require("../../utils/weather_mocker");
describe("Weather module: Weather Hourly Forecast", () => {
afterAll(async () => {
await helpers.stopApplication();
await cleanupMockData();
await weatherFunc.stopApplication();
});
describe("Default configuration", () => {
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"];
@@ -23,7 +20,7 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Hourly weather options", () => {
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", () => {
@@ -38,7 +35,7 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Show precipitations", () => {
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", () => {

View File

@@ -2,11 +2,17 @@ const delay = (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", () => {
let serverProcess;
beforeAll(async () => {
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
await delay(2000);
});
@@ -24,3 +30,15 @@ describe("App environment", () => {
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 { JSDOM } = require("jsdom");
const express = require("express");
const sinon = require("sinon");
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", () => {
let server;
@@ -37,91 +54,76 @@ describe("translations", () => {
let dom;
beforeEach(() => {
// Create a new JSDOM instance for each test
dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" });
// Create a new translation test environment for each test
const env = createTranslationTestEnvironment();
const window = env.window;
// Mock the necessary global objects
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
// Load class.js and module.js content directly for loadTranslations tests
const classJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "class.js"), "utf-8");
const moduleJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "module.js"), "utf-8");
// Execute the scripts in the JSDOM context
dom.window.eval(classJs);
dom.window.eval(moduleJs);
window.eval(classJs);
window.eval(moduleJs);
// Additional setup for loadTranslations tests
window.config = { language: "de" };
dom = { window };
});
it("should load translation file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module, config } = dom.window;
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 });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", false)).toBe(true);
expect(Translator.load.mock.calls).toHaveLength(1);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", false);
});
it("should load translation + fallback file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
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 });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(2);
expect(Translator.load.calledWith(MMM, "translations/de.json", false)).toBe(true);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true);
expect(Translator.load.mock.calls).toHaveLength(2);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/de.json", false);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true);
});
it("should load translation fallback file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module, config } = dom.window;
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 });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true);
expect(Translator.load.mock.calls).toHaveLength(1);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true);
});
it("should load no file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module } = dom.window;
Translator.load = sinon.stub();
Translator.load = jest.fn();
Module.register("name", {});
const MMM = Module.create("name");
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", () => {
for (const language in translations) {
it(`should parse ${language}`, async () => {
const dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" });
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;
const { Translator } = createTranslationTestEnvironment();
await Translator.load(mmm, translations[language], false);
expect(typeof Translator.translations[mmm.name]).toBe("object");
@@ -177,19 +168,10 @@ describe("translations", () => {
};
// Function to initialize JSDOM and load translations
const initializeTranslationDOM = (language) => {
const dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" });
dom.window.Log = { log: jest.fn() };
dom.window.translations = translations;
dom.window.eval(translatorJs);
return new Promise((resolve) => {
dom.window.onload = async () => {
const { Translator } = dom.window;
const initializeTranslationDOM = async (language) => {
const { Translator } = createTranslationTestEnvironment();
await Translator.load(mmm, translations[language], false);
resolve(Translator.translations[mmm.name]);
};
});
return Translator.translations[mmm.name];
};
beforeAll(async () => {

View File

@@ -9,7 +9,7 @@ describe("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) => {
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 { 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", () => {
afterEach(async () => {
await helpers.stopApplication();
await cleanupMockData();
cleanupMockData();
});
describe("Current weather with sunrise", () => {
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 () => {
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", () => {
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 () => {
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 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", () => {
let server;
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"));
beforeAll(() => {
@@ -20,7 +32,7 @@ describe("Translator", () => {
});
app.use("/translations", express.static(path.join(__dirname, "..", "..", "..", "tests", "mocks")));
server = app.listen(3000);
server = app.listen(3001);
server.on("connection", (socket) => {
sockets.add(socket);
@@ -81,12 +93,7 @@ describe("Translator", () => {
};
it("should return custom module translation", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "Hello");
@@ -97,12 +104,7 @@ describe("Translator", () => {
});
it("should return core translation", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "FOO");
expect(translation).toBe("Foo");
@@ -111,48 +113,28 @@ describe("Translator", () => {
});
it("should return custom module translation fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "A key");
expect(translation).toBe("A translation");
});
it("should return core translation fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Fallback");
expect(translation).toBe("core fallback");
});
it("should return translation with placeholder for missing variables", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}");
expect(translation).toBe("Hallo {username}");
});
it("should return key if no translation was found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "MISSING");
expect(translation).toBe("MISSING");
@@ -163,17 +145,12 @@ describe("Translator", () => {
const mmm = {
name: "TranslationTest",
file (file) {
return `http://localhost:3000/translations/${file}`;
return `http://localhost:3001/translations/${file}`;
}
};
it("should load translations", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
const file = "translation_test.json";
await Translator.load(mmm, file, false);
@@ -182,30 +159,18 @@ describe("Translator", () => {
});
it("should load translation fallbacks", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
const file = "translation_test.json";
dom.window.Log = { log: jest.fn() };
await Translator.load(mmm, file, true);
const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8"));
expect(Translator.translationsFallback[mmm.name]).toEqual(json);
});
it("should not load translations, if module fallback exists", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { Translator } = createTranslationTestEnvironment();
const file = "translation_test.json";
dom.window.Log = { log: jest.fn() };
Translator.translationsFallback[mmm.name] = {
Hello: "Hallo"
};
@@ -220,37 +185,23 @@ describe("Translator", () => {
describe("loadCoreTranslations", () => {
it("should load core translations and fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
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;
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslations("en");
const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslations).toEqual(en);
expect(Translator.coreTranslationsFallback).toEqual(en);
});
it("should load core fallback if language cannot be found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
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;
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslations("MISSINGLANG");
const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslations).toEqual({});
expect(Translator.coreTranslationsFallback).toEqual(en);
});
@@ -258,35 +209,20 @@ describe("Translator", () => {
describe("loadCoreTranslationsFallback", () => {
it("should load core translations fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
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;
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslationsFallback();
const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslationsFallback).toEqual(en);
});
it("should load core fallback if language cannot be found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
dom.window.translations = {};
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const { window, Translator } = createTranslationTestEnvironment();
window.translations = {};
await Translator.loadCoreTranslations();
await new Promise((resolve) => setTimeout(resolve, 500));
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`] = `
{

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("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][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, "../../..");
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) {
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 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", () => {
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: [],
includePastEvents: false,
maximumEntries: 10,
maximumNumberOfDays: 365
maximumNumberOfDays: 367
};
describe("filterEvents", () => {

View File

@@ -2,10 +2,18 @@ const weather = require("../../../../../modules/default/weather/weatherutils");
const WeatherUtils = require("../../../../../modules/default/weather/weatherutils");
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", () => {
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", () => {
@@ -44,11 +52,11 @@ describe("Weather utils tests", () => {
describe("feelsLike calculation", () => {
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)", () => {
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 path = require("node:path");
const util = require("node:util");
const exec = util.promisify(require("node:child_process").exec);
const exec = require("node:child_process").execSync;
/**
* @param {string} type what data to read, can be "current" "forecast" or "hourly
@@ -45,9 +44,9 @@ const injectMockData = (configFileName, extendedData = {}) => {
return tempFile;
};
const cleanupMockData = async () => {
const cleanupMockData = () => {
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 };