Release 2.22.0 (#2983)

## [2.22.0] - 2023-01-01

Thanks to: @angeldeejay, @buxxi, @dariom, @dWoolridge,
@KristjanESPERANTO, @MagMar94, @naveensrinivasan, @retroflex, @SkySails
and @Tom.

Special thanks to @khassel, @rejas and @sdetweil for taking over most
(if not all) of the work on this release as project collaborators. This
version would not be there without their effort. Thank you!

### Added

- Added test for remoteFile option in compliments module
- Added hourlyWeather functionality to Weather.gov weather provider
- Removed weatherEndpoint definition from weathergov.js (not used)
- Added css class names "today" and "tomorrow" for default calendar
- Added Collaboration.md
- Added new github action for dependency review (#2862)
- Added a WeatherProvider for Open-Meteo
- Added Yr as a weather provider
- Added config options "ignoreXOriginHeader" and
"ignoreContentSecurityPolicy"

### Removed

- Removed usage of internal fetch function of node until it is more
stable

### Updated

- Cleaned up test directory (#2937) and jest config (#2959)
- Wait for all modules to start before declaring the system ready
(#2487)
- Updated e2e tests (moved `done()` in helper functions) and use es6
syntax in all tests
- Updated da translation
- Rework weather module
- Make sure smhi provider api only gets a maximum of 6 digits
coordinates (#2955)
  - Use fetch instead of XMLHttpRequest in weatherprovider (#2935)
  - Reworked how weatherproviders handle units (#2849)
  - Use unix() method for parsing times, fix suntimes on the way (#2950)
  - Refactor conversion functions into utils class (#2958)
- The `cors`-method in `server.js` now supports sending and recieving
HTTP headers
- Replace `…` by `…`
- Cleanup compliments module
- Updated dependencies including electron to v22 (#2903)

### Fixed

- Correctly show apparent temperature in SMHI weather provider
- Ensure updatenotification module isn't shown when local is _ahead_ of
remote
- Handle node_helper errors during startup (#2944)
- Possibility to change FontAwesome class in calendar, so icons like
`fab fa-facebook-square` works.
- Fix cors problems with newsfeed articles (as far as possible), allow
disabling cors per feed with option `useCorsProxy: false` (#2840)
- Tests not waiting for the application to start and stop before
starting the next test
- Fix electron tests failing sometimes in github workflow
- Fixed gap in clock module when displayed on the left side with
displayType=digital
- Fixed playwright issue by upgrading to v1.29.1 (#2969)

Signed-off-by: naveen <172697+naveensrinivasan@users.noreply.github.com>
Co-authored-by: Karsten Hassel <hassel@gmx.de>
Co-authored-by: Malte Hallström <46646495+SkySails@users.noreply.github.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: veeck <michael@veeck.de>
Co-authored-by: dWoolridge <dwoolridge@charter.net>
Co-authored-by: Johan <jojjepersson@yahoo.se>
Co-authored-by: Dario Mratovich <dario_mratovich@hotmail.com>
Co-authored-by: Dario Mratovich <dario.mratovich@outlook.com>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Naveen <172697+naveensrinivasan@users.noreply.github.com>
Co-authored-by: buxxi <buxxi@omfilm.net>
Co-authored-by: Thomas Hirschberger <47733292+Tom-Hirschberger@users.noreply.github.com>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com>
This commit is contained in:
Michael Teeuw 2023-01-01 18:09:08 +01:00 committed by GitHub
parent 9e0293047f
commit 0300ce05d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
151 changed files with 5890 additions and 4949 deletions

14
.github/workflows/depsreview.yaml vendored Normal file
View file

@ -0,0 +1,14 @@
name: "Dependency Review"
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v3
- name: "Dependency Review"
uses: actions/dependency-review-action@v2

1
.gitignore vendored
View file

@ -16,7 +16,6 @@ vendor/node_modules/**/*
jspm_modules
.npm
.node_repl_history
.nyc_output/
# Visual Studio Code ignoramuses.
.vscode/

View file

@ -1,4 +1,3 @@
/config
/coverage
.nyc_output
package-lock.json

View file

@ -5,30 +5,82 @@ This project adheres to [Semantic Versioning](https://semver.org/).
❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/donate) With your help we can continue to improve the MagicMirror².
## [2.22.0] - 2023-01-01
Thanks to: @angeldeejay, @buxxi, @dariom, @dWoolridge, @KristjanESPERANTO, @MagMar94, @naveensrinivasan, @retroflex, @SkySails and @Tom.
Special thanks to @khassel, @rejas and @sdetweil for taking over most (if not all) of the work on this release as project collaborators. This version would not be there without their effort. Thank you!
### Added
- Added test for remoteFile option in compliments module
- Added hourlyWeather functionality to Weather.gov weather provider
- Removed weatherEndpoint definition from weathergov.js (not used)
- Added css class names "today" and "tomorrow" for default calendar
- Added Collaboration.md
- Added new github action for dependency review (#2862)
- Added a WeatherProvider for Open-Meteo
- Added Yr as a weather provider
- Added config options "ignoreXOriginHeader" and "ignoreContentSecurityPolicy"
### Removed
- Removed usage of internal fetch function of node until it is more stable
### Updated
- Cleaned up test directory (#2937) and jest config (#2959)
- Wait for all modules to start before declaring the system ready (#2487)
- Updated e2e tests (moved `done()` in helper functions) and use es6 syntax in all tests
- Updated da translation
- Rework weather module
- Make sure smhi provider api only gets a maximum of 6 digits coordinates (#2955)
- Use fetch instead of XMLHttpRequest in weatherprovider (#2935)
- Reworked how weatherproviders handle units (#2849)
- Use unix() method for parsing times, fix suntimes on the way (#2950)
- Refactor conversion functions into utils class (#2958)
- The `cors`-method in `server.js` now supports sending and recieving HTTP headers
- Replace `&hellip;` by `…`
- Cleanup compliments module
- Updated dependencies including electron to v22 (#2903)
### Fixed
- Correctly show apparent temperature in SMHI weather provider
- Ensure updatenotification module isn't shown when local is _ahead_ of remote
- Handle node_helper errors during startup (#2944)
- Possibility to change FontAwesome class in calendar, so icons like `fab fa-facebook-square` works.
- Fix cors problems with newsfeed articles (as far as possible), allow disabling cors per feed with option `useCorsProxy: false` (#2840)
- Tests not waiting for the application to start and stop before starting the next test
- Fix electron tests failing sometimes in github workflow
- Fixed gap in clock module when displayed on the left side with displayType=digital
- Fixed playwright issue by upgrading to v1.29.1 (#2969)
## [2.21.0] - 2022-10-01
Special thanks to: @BKeyport, @buxxi, @davide125, @khassel, @kolbyjack, @krukle, @MikeBishop, @rejas, @sdetweil, @SkySails and @veeck
## Added
### Added
- Possibility to fetch calendars through socket notifications.
- Added possibility to fetch calendars through socket notifications.
- New scripts `install-mm` (and `install-mm:dev`) for simplifying mm installation (now: `npm run install-mm`) and adding params `--no-audit --no-fund --no-update-notifier` for less noise.
- New `showTimeToday` option in calendar module shows time for current-day events even if `timeFormat` is `"relative"`.
- Add hourly forecasts, apparent temperature & custom location name to SMHI weather provider.
- Added hourly forecasts, apparent temperature & custom location name to SMHI weather provider.
- Added new electron tests for calendar and moved some compliments tests from `e2e` to `electron` because of date mocking, removed mock stuff from compliments module.
## Removed
- Old weather deprecated modules `currentweather` and `weatherforecast`.
## Updated
### Removed
- Removed old and deprecated weather modules `currentweather` and `weatherforecast`.
- Removed `DAYAFTERTOMORROW` from English.
- Update dependencies.
### Updated
- Updated dependencies.
- Updated jsdoc.
- Updated font tree to use variables consistantly.
- Updated font tree to use variables consistently.
- Removed deprecated Docker Repository from issue template.
## Fixed
### Fixed
- Broadcast all calendar events while still honoring global and per-calendar maximumEntries.
- Respect rss ttl provided by newsfeed (#2883).

12
Collaboration.md Normal file
View file

@ -0,0 +1,12 @@
This document describes how collaborators of this repository should work together.
## Pull Requests
- never merge your own PR's
- never merge without someone having approved (approving and merging from same person is allowed)
- wait for all approvals requested (or the author decides something different in the comments)
## Issues
- "real" Issues are closed if the problem is solved and the fix is released
- unrelated Issues (e.g. related to a foreign module) are closed immediately with a comment to open an issue in the module repository or to discuss this further in the forum or discord

32
jest.config.js Normal file
View file

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

View file

@ -222,18 +222,33 @@ function App() {
}
}
loadModules(modules, function () {
httpServer = new Server(config, function (app, io) {
Log.log("Server started ...");
loadModules(modules, async function () {
httpServer = new Server(config);
const { app, io } = await httpServer.open();
Log.log("Server started ...");
for (let nodeHelper of nodeHelpers) {
nodeHelper.setExpressApp(app);
nodeHelper.setSocketIO(io);
nodeHelper.start();
const nodePromises = [];
for (let nodeHelper of nodeHelpers) {
nodeHelper.setExpressApp(app);
nodeHelper.setSocketIO(io);
try {
nodePromises.push(nodeHelper.start());
} catch (error) {
Log.error(`Error when starting node_helper for module ${nodeHelper.name}:`);
Log.error(error);
}
}
Promise.allSettled(nodePromises).then((results) => {
// Log errors that happened during async node_helper startup
results.forEach((result) => {
if (result.status === "rejected") {
Log.error(result.reason);
}
});
Log.log("Sockets connected & modules started ...");
if (typeof callback === "function") {
callback(config);
}
@ -247,14 +262,16 @@ function App() {
* exists.
*
* Added to fix #1056
*
* @param {Function} callback Function to be called after the app has stopped
*/
this.stop = function () {
this.stop = function (callback) {
for (const nodeHelper of nodeHelpers) {
if (typeof nodeHelper.stop === "function") {
nodeHelper.stop();
}
}
httpServer.close();
httpServer.close().then(callback);
};
/**

View file

@ -103,6 +103,20 @@ function createWindow() {
}, 1000);
});
}
//remove response headers that prevent sites of being embedded into iframes if configured
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
let curHeaders = details.responseHeaders;
if (config["ignoreXOriginHeader"] || false) {
curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !/x-frame-options/i.test(header[0])));
}
if (config["ignoreContentSecurityPolicy"] || false) {
curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !/content-security-policy/i.test(header[0])));
}
callback({ responseHeaders: curHeaders });
});
}
// This method will be called when Electron has finished

View file

@ -1,20 +1,28 @@
/**
* fetch
* Helper class to provide either third party fetch library or (if node >= 18)
* return internal node fetch implementation.
*
* Attention: After some discussion we always return the third party
* implementation until the node implementation is stable and more tested
*
* @see https://github.com/MichMich/MagicMirror/pull/2952
* @see https://github.com/MichMich/MagicMirror/issues/2649
* @param {string} url to be fetched
* @param {object} options object e.g. for headers
* @class
*/
async function fetch(url, options) {
const nodeVersion = process.version.match(/^v(\d+)\.*/)[1];
if (nodeVersion >= 18) {
// node version >= 18
return global.fetch(url, options);
} else {
// node version < 18
const nodefetch = require("node-fetch");
return nodefetch(url, options);
}
async function fetch(url, options = {}) {
// const nodeVersion = process.version.match(/^v(\d+)\.*/)[1];
// if (nodeVersion >= 18) {
// // node version >= 18
// return global.fetch(url, options);
// } else {
// // node version < 18
// const nodefetch = require("node-fetch");
// return nodefetch(url, options);
// }
const nodefetch = require("node-fetch");
return nodefetch(url, options);
}
module.exports = fetch;

View file

@ -70,7 +70,7 @@ const MM = (function () {
* Select the wrapper dom object for a specific position.
*
* @param {string} position The name of the position.
* @returns {HTMLElement} the wrapper element
* @returns {HTMLElement | void} the wrapper element
*/
const selectWrapper = function (position) {
const classes = position.replace("_", " ");

View file

@ -5,136 +5,114 @@
* MIT Licensed.
*/
const express = require("express");
const app = require("express")();
const path = require("path");
const ipfilter = require("express-ipfilter").IpFilter;
const fs = require("fs");
const helmet = require("helmet");
const fetch = require("fetch");
const Log = require("logger");
const Utils = require("./utils.js");
const { cors, getConfig, getHtml, getVersion } = require("./server_functions.js");
/**
* Server
*
* @param {object} config The MM config
* @param {Function} callback Function called when done.
* @class
*/
function Server(config, callback) {
function Server(config) {
const app = express();
const port = process.env.MM_PORT || config.port;
const serverSockets = new Set();
let server = null;
if (config.useHttps) {
const options = {
key: fs.readFileSync(config.httpsPrivateKey),
cert: fs.readFileSync(config.httpsCertificate)
};
server = require("https").Server(options, app);
} else {
server = require("http").Server(app);
}
const io = require("socket.io")(server, {
cors: {
origin: /.*$/,
credentials: true
},
allowEIO3: true
});
server.on("connection", (socket) => {
serverSockets.add(socket);
socket.on("close", () => {
serverSockets.delete(socket);
});
});
Log.log(`Starting server on port ${port} ... `);
server.listen(port, config.address || "localhost");
if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) {
Log.warn(Utils.colors.warn("You're using a full whitelist configuration to allow for all IPs"));
}
app.use(function (req, res, next) {
ipfilter(config.ipWhitelist, { mode: config.ipWhitelist.length === 0 ? "deny" : "allow", log: false })(req, res, function (err) {
if (err === undefined) {
res.header("Access-Control-Allow-Origin", "*");
return next();
}
Log.log(err.message);
res.status(403).send("This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.");
});
});
app.use(helmet(config.httpHeaders));
app.use("/js", express.static(__dirname));
const directories = ["/config", "/css", "/fonts", "/modules", "/vendor", "/translations", "/tests/configs"];
for (const directory of directories) {
app.use(directory, express.static(path.resolve(global.root_path + directory)));
}
app.get("/cors", async function (req, res) {
// example: http://localhost:8080/cors?url=https://google.de
try {
const reg = "^/cors.+url=(.*)";
let url = "";
let match = new RegExp(reg, "g").exec(req.url);
if (!match) {
url = "invalid url: " + req.url;
Log.error(url);
res.send(url);
/**
* Opens the server for incoming connections
*
* @returns {Promise} A promise that is resolved when the server listens to connections
*/
this.open = function () {
return new Promise((resolve) => {
if (config.useHttps) {
const options = {
key: fs.readFileSync(config.httpsPrivateKey),
cert: fs.readFileSync(config.httpsCertificate)
};
server = require("https").Server(options, app);
} else {
url = match[1];
Log.log("cors url: " + url);
const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 MagicMirror/" + global.version } });
const header = response.headers.get("Content-Type");
const data = await response.text();
if (header) res.set("Content-Type", header);
res.send(data);
server = require("http").Server(app);
}
} catch (error) {
Log.error(error);
res.send(error);
}
});
const io = require("socket.io")(server, {
cors: {
origin: /.*$/,
credentials: true
},
allowEIO3: true
});
app.get("/version", function (req, res) {
res.send(global.version);
});
server.on("connection", (socket) => {
serverSockets.add(socket);
socket.on("close", () => {
serverSockets.delete(socket);
});
});
app.get("/config", function (req, res) {
res.send(config);
});
Log.log(`Starting server on port ${port} ... `);
server.listen(port, config.address || "localhost");
app.get("/", function (req, res) {
let html = fs.readFileSync(path.resolve(`${global.root_path}/index.html`), { encoding: "utf8" });
html = html.replace("#VERSION#", global.version);
if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) {
Log.warn(Utils.colors.warn("You're using a full whitelist configuration to allow for all IPs"));
}
let configFile = "config/config.js";
if (typeof global.configuration_file !== "undefined") {
configFile = global.configuration_file;
}
html = html.replace("#CONFIG_FILE#", configFile);
app.use(function (req, res, next) {
ipfilter(config.ipWhitelist, { mode: config.ipWhitelist.length === 0 ? "deny" : "allow", log: false })(req, res, function (err) {
if (err === undefined) {
res.header("Access-Control-Allow-Origin", "*");
return next();
}
Log.log(err.message);
res.status(403).send("This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.");
});
});
res.send(html);
});
app.use(helmet(config.httpHeaders));
app.use("/js", express.static(__dirname));
if (typeof callback === "function") {
callback(app, io);
}
// TODO add tests directory only when running tests?
const directories = ["/config", "/css", "/fonts", "/modules", "/vendor", "/translations", "/tests/configs", "/tests/mocks"];
for (const directory of directories) {
app.use(directory, express.static(path.resolve(global.root_path + directory)));
}
app.get("/cors", async (req, res) => await cors(req, res));
app.get("/version", (req, res) => getVersion(req, res));
app.get("/config", (req, res) => getConfig(req, res));
app.get("/", (req, res) => getHtml(req, res));
server.on("listening", () => {
resolve({
app,
io
});
});
});
};
/**
* Closes the server and destroys all lingering connections to it.
*
* @returns {Promise} A promise that resolves when server has successfully shut down
*/
this.close = function () {
for (const socket of serverSockets.values()) {
socket.destroy();
}
server.close();
return new Promise((resolve) => {
for (const socket of serverSockets.values()) {
socket.destroy();
}
server.close(resolve);
});
};
}

127
js/server_functions.js Normal file
View file

@ -0,0 +1,127 @@
const fetch = require("./fetch");
const fs = require("fs");
const path = require("path");
const Log = require("logger");
/**
* Gets the config.
*
* @param {Request} req - the request
* @param {Response} res - the result
*/
function getConfig(req, res) {
res.send(config);
}
/**
* A method that forewards HTTP Get-methods to the internet to avoid CORS-errors.
*
* Example input request url: /cors?sendheaders=header1:value1,header2:value2&expectedheaders=header1,header2&url=http://www.test.com/path?param1=value1
*
* Only the url-param of the input request url is required. It must be the last parameter.
*
* @param {Request} req - the request
* @param {Response} res - the result
*/
async function cors(req, res) {
try {
const urlRegEx = "url=(.+?)$";
let url = "";
const match = new RegExp(urlRegEx, "g").exec(req.url);
if (!match) {
url = "invalid url: " + req.url;
Log.error(url);
res.send(url);
} else {
url = match[1];
const headersToSend = getHeadersToSend(req.url);
const expectedRecievedHeaders = geExpectedRecievedHeaders(req.url);
Log.log("cors url: " + url);
const response = await fetch(url, { headers: headersToSend });
for (const header of expectedRecievedHeaders) {
const headerValue = response.headers.get(header);
if (header) res.set(header, headerValue);
}
const data = await response.text();
res.send(data);
}
} catch (error) {
Log.error(error);
res.send(error);
}
}
/**
* Gets headers and values to attatch to the web request.
*
* @param {string} url - The url containing the headers and values to send.
* @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 headersToSendMatch = new RegExp("sendheaders=(.+?)(&|$)", "g").exec(url);
if (headersToSendMatch) {
const headers = headersToSendMatch[1].split(",");
for (const header of headers) {
const keyValue = header.split(":");
if (keyValue.length !== 2) {
throw new Error(`Invalid format for header ${header}`);
}
headersToSend[keyValue[0]] = decodeURIComponent(keyValue[1]);
}
}
return headersToSend;
}
/**
* Gets the headers expected from the response.
*
* @param {string} url - The url containing the expected headers from the response.
* @returns {string[]} headers - The name of the expected headers.
*/
function geExpectedRecievedHeaders(url) {
const expectedRecievedHeaders = ["Content-Type"];
const expectedRecievedHeadersMatch = new RegExp("expectedheaders=(.+?)(&|$)", "g").exec(url);
if (expectedRecievedHeadersMatch) {
const headers = expectedRecievedHeadersMatch[1].split(",");
for (const header of headers) {
expectedRecievedHeaders.push(header);
}
}
return expectedRecievedHeaders;
}
/**
* Gets the HTML to display the magic mirror.
*
* @param {Request} req - the request
* @param {Response} res - the result
*/
function getHtml(req, res) {
let html = fs.readFileSync(path.resolve(`${global.root_path}/index.html`), { encoding: "utf8" });
html = html.replace("#VERSION#", global.version);
let configFile = "config/config.js";
if (typeof global.configuration_file !== "undefined") {
configFile = global.configuration_file;
}
html = html.replace("#CONFIG_FILE#", configFile);
res.send(html);
}
/**
* Gets the MagicMirror version.
*
* @param {Request} req - the request
* @param {Response} res - the result
*/
function getVersion(req, res) {
res.send(global.version);
}
module.exports = { cors, getConfig, getHtml, getVersion };

View file

@ -14,6 +14,7 @@ Module.register("calendar", {
limitDays: 0, // Limit the number of days shown, 0 = no limit
displaySymbol: true,
defaultSymbol: "calendar-alt", // Fontawesome Symbol see https://fontawesome.com/cheatsheet?from=io
defaultSymbolClassName: "fas fa-fw fa-",
showLocation: false,
displayRepeatingCountTitle: false,
defaultRepeatingCountTitle: "",
@ -163,11 +164,10 @@ Module.register("calendar", {
// Override dom generator.
getDom: function () {
// Define second, minute, hour, and day constants
const oneSecond = 1000; // 1,000 milliseconds
const oneMinute = oneSecond * 60;
const oneHour = oneMinute * 60;
const oneDay = oneHour * 24;
const ONE_SECOND = 1000; // 1,000 milliseconds
const ONE_MINUTE = ONE_SECOND * 60;
const ONE_HOUR = ONE_MINUTE * 60;
const ONE_DAY = ONE_HOUR * 24;
const events = this.createEventList(true);
const wrapper = document.createElement("table");
@ -205,6 +205,8 @@ Module.register("calendar", {
if (lastSeenDate !== dateAsString) {
const dateRow = document.createElement("tr");
dateRow.className = "normal";
if (event.today) dateRow.className += " today";
else if (event.tomorrow) dateRow.className += " tomorrow";
const dateCell = document.createElement("td");
dateCell.colSpan = "3";
@ -230,6 +232,8 @@ Module.register("calendar", {
}
eventWrapper.className = "normal event";
if (event.today) eventWrapper.className += " today";
else if (event.tomorrow) eventWrapper.className += " tomorrow";
const symbolWrapper = document.createElement("td");
@ -244,7 +248,7 @@ Module.register("calendar", {
const symbols = this.symbolsForEvent(event);
symbols.forEach((s, index) => {
const symbol = document.createElement("span");
symbol.className = "fas fa-fw fa-" + s;
symbol.className = s;
if (index > 0) {
symbol.style.paddingLeft = "5px";
}
@ -338,7 +342,7 @@ Module.register("calendar", {
// For full day events we use the fullDayEventDateFormat
if (event.fullDayEvent) {
//subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day
event.endDate -= oneSecond;
event.endDate -= ONE_SECOND;
timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat));
} else if (this.config.getRelative > 0 && event.startDate < now) {
// Ongoing and getRelative is set
@ -348,7 +352,7 @@ Module.register("calendar", {
timeUntilEnd: moment(event.endDate, "x").fromNow(true)
})
);
} else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * oneDay) {
} else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * ONE_DAY) {
// Within urgency days
timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").fromNow());
}
@ -356,9 +360,9 @@ Module.register("calendar", {
// Full days events within the next two days
if (event.today) {
timeWrapper.innerHTML = this.capFirst(this.translate("TODAY"));
} else if (event.startDate - now < oneDay && event.startDate - now > 0) {
} else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
timeWrapper.innerHTML = this.capFirst(this.translate("TOMORROW"));
} else if (event.startDate - now < 2 * oneDay && event.startDate - now > 0) {
} else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
timeWrapper.innerHTML = this.capFirst(this.translate("DAYAFTERTOMORROW"));
}
@ -367,7 +371,7 @@ Module.register("calendar", {
} else {
// Show relative times
if (event.startDate >= now || (event.fullDayEvent && event.today)) {
// Use relative time
// Use relative time
if (!this.config.hideTime && !event.fullDayEvent) {
timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat }));
} else {
@ -384,14 +388,14 @@ Module.register("calendar", {
// Full days events within the next two days
if (event.today) {
timeWrapper.innerHTML = this.capFirst(this.translate("TODAY"));
} else if (event.startDate - now < oneDay && event.startDate - now > 0) {
} else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
timeWrapper.innerHTML = this.capFirst(this.translate("TOMORROW"));
} else if (event.startDate - now < 2 * oneDay && event.startDate - now > 0) {
} else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
timeWrapper.innerHTML = this.capFirst(this.translate("DAYAFTERTOMORROW"));
}
}
} else if (event.startDate - now < this.config.getRelative * oneHour) {
} else if (event.startDate - now < this.config.getRelative * ONE_HOUR) {
// If event is within getRelative hours, display 'in xxx' time format or moment.fromNow()
timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").fromNow());
}
@ -421,6 +425,8 @@ Module.register("calendar", {
if (event.location !== false) {
const locationRow = document.createElement("tr");
locationRow.className = "normal xsmall light";
if (event.today) locationRow.className += " today";
else if (event.tomorrow) locationRow.className += " tomorrow";
if (this.config.displaySymbol) {
const symbolCell = document.createElement("td");
@ -491,6 +497,11 @@ Module.register("calendar", {
* @returns {object[]} Array with events.
*/
createEventList: function (limitNumberOfEntries) {
const ONE_SECOND = 1000; // 1,000 milliseconds
const ONE_MINUTE = ONE_SECOND * 60;
const ONE_HOUR = ONE_MINUTE * 60;
const ONE_DAY = ONE_HOUR * 24;
const now = new Date();
const today = moment().startOf("day");
const future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate();
@ -521,19 +532,21 @@ Module.register("calendar", {
}
}
event.url = calendarUrl;
event.today = event.startDate >= today && event.startDate < today + 24 * 60 * 60 * 1000;
event.today = event.startDate >= today && event.startDate < today + ONE_DAY;
event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY;
/* if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days,
* otherwise, esp. in dateheaders mode it is not clear how long these events are.
*/
const maxCount = Math.ceil((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / (1000 * 60 * 60 * 24)) + 1;
const maxCount = Math.ceil((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / ONE_DAY) + 1;
if (this.config.sliceMultiDayEvents && maxCount > 1) {
const splitEvents = [];
let midnight = moment(event.startDate, "x").clone().startOf("day").add(1, "day").format("x");
let count = 1;
while (event.endDate > midnight) {
const thisEvent = JSON.parse(JSON.stringify(event)); // clone object
thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + 24 * 60 * 60 * 1000;
thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + ONE_DAY;
thisEvent.tomorrow = !thisEvent.today && thisEvent.startDate >= today + ONE_DAY && thisEvent.startDate < today + 2 * ONE_DAY;
thisEvent.endDate = midnight;
thisEvent.title += " (" + count + "/" + maxCount + ")";
splitEvents.push(thisEvent);
@ -544,6 +557,8 @@ Module.register("calendar", {
}
// Last day
event.title += " (" + count + "/" + maxCount + ")";
event.today += event.startDate >= today && event.startDate < today + ONE_DAY;
event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY;
splitEvents.push(event);
for (let splitEvent of splitEvents) {
@ -757,6 +772,11 @@ Module.register("calendar", {
getCalendarPropertyAsArray: function (url, property, defaultValue) {
let p = this.getCalendarProperty(url, property, defaultValue);
if (property === "symbol" || property === "recurringSymbol" || property === "fullDaySymbol") {
const className = this.getCalendarProperty(url, "symbolClassName", this.config.defaultSymbolClassName);
p = className + p;
}
if (!(p instanceof Array)) p = [p];
return p;
},
@ -794,7 +814,7 @@ Module.register("calendar", {
line++;
if (line > maxTitleLines - 1) {
if (i < words.length) {
currentLine += "&hellip;";
currentLine += "";
}
break;
}
@ -811,7 +831,7 @@ Module.register("calendar", {
return (temp + currentLine).trim();
} else {
if (maxLength && typeof maxLength === "number" && string.length > maxLength) {
return string.trim().slice(0, maxLength) + "&hellip;";
return string.trim().slice(0, maxLength) + "";
} else {
return string.trim();
}

View file

@ -303,7 +303,7 @@ Module.register("clock", {
}
/*******************************************
* Update placement, respect old analogShowDate even if its not needed anymore
* Update placement, respect old analogShowDate even if it's not needed anymore
*/
if (this.config.displayType === "analog") {
// Display only an analog clock
@ -311,16 +311,16 @@ Module.register("clock", {
wrapper.classList.add("clockGrid--bottom");
} else if (this.config.analogShowDate === "bottom") {
wrapper.classList.add("clockGrid--top");
} else {
//analogWrapper.style.gridArea = "center";
}
wrapper.appendChild(analogWrapper);
} else if (this.config.displayType === "digital") {
wrapper.appendChild(digitalWrapper);
} else if (this.config.displayType === "both") {
wrapper.classList.add("clockGrid--" + this.config.analogPlacement);
wrapper.appendChild(analogWrapper);
wrapper.appendChild(digitalWrapper);
}
wrapper.appendChild(analogWrapper);
wrapper.appendChild(digitalWrapper);
// Return the wrapper to the dom.
return wrapper;
}

View file

@ -21,8 +21,7 @@ Module.register("compliments", {
morningEndTime: 12,
afternoonStartTime: 12,
afternoonEndTime: 17,
random: true,
mockDate: null
random: true
},
lastIndexUsed: -1,
// Set currentweather from module
@ -40,7 +39,7 @@ Module.register("compliments", {
this.lastComplimentIndex = -1;
if (this.config.remoteFile !== null) {
this.complimentFile((response) => {
this.loadComplimentFile().then((response) => {
this.config.compliments = JSON.parse(response);
this.updateDom();
});
@ -85,30 +84,30 @@ Module.register("compliments", {
*/
complimentArray: function () {
const hour = moment().hour();
const date = this.config.mockDate ? this.config.mockDate : moment().format("YYYY-MM-DD");
let compliments;
const date = moment().format("YYYY-MM-DD");
let compliments = [];
// Add time of day compliments
if (hour >= this.config.morningStartTime && hour < this.config.morningEndTime && this.config.compliments.hasOwnProperty("morning")) {
compliments = this.config.compliments.morning.slice(0);
compliments = [...this.config.compliments.morning];
} else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime && this.config.compliments.hasOwnProperty("afternoon")) {
compliments = this.config.compliments.afternoon.slice(0);
compliments = [...this.config.compliments.afternoon];
} else if (this.config.compliments.hasOwnProperty("evening")) {
compliments = this.config.compliments.evening.slice(0);
}
if (typeof compliments === "undefined") {
compliments = [];
compliments = [...this.config.compliments.evening];
}
// Add compliments based on weather
if (this.currentWeatherType in this.config.compliments) {
compliments.push.apply(compliments, this.config.compliments[this.currentWeatherType]);
Array.prototype.push.apply(compliments, this.config.compliments[this.currentWeatherType]);
}
compliments.push.apply(compliments, this.config.compliments.anytime);
// Add compliments for anytime
Array.prototype.push.apply(compliments, this.config.compliments.anytime);
// Add compliments for special days
for (let entry in this.config.compliments) {
if (new RegExp(entry).test(date)) {
compliments.push.apply(compliments, this.config.compliments[entry]);
Array.prototype.push.apply(compliments, this.config.compliments[entry]);
}
}
@ -118,20 +117,13 @@ Module.register("compliments", {
/**
* Retrieve a file from the local filesystem
*
* @param {Function} callback Called when the file is retrieved.
* @returns {Promise} Resolved when the file is loaded
*/
complimentFile: function (callback) {
const xobj = new XMLHttpRequest(),
isRemote = this.config.remoteFile.indexOf("http://") === 0 || this.config.remoteFile.indexOf("https://") === 0,
path = isRemote ? this.config.remoteFile : this.file(this.config.remoteFile);
xobj.overrideMimeType("application/json");
xobj.open("GET", path, true);
xobj.onreadystatechange = function () {
if (xobj.readyState === 4 && xobj.status === 200) {
callback(xobj.responseText);
}
};
xobj.send(null);
loadComplimentFile: async function () {
const isRemote = this.config.remoteFile.indexOf("http://") === 0 || this.config.remoteFile.indexOf("https://") === 0,
url = isRemote ? this.config.remoteFile : this.file(this.config.remoteFile);
const response = await fetch(url);
return await response.text();
},
/**
@ -139,7 +131,7 @@ Module.register("compliments", {
*
* @returns {string} a compliment
*/
randomCompliment: function () {
getRandomCompliment: function () {
// get the current time of day compliments list
const compliments = this.complimentArray();
// variable for index to next message to display
@ -162,34 +154,33 @@ Module.register("compliments", {
const wrapper = document.createElement("div");
wrapper.className = this.config.classes ? this.config.classes : "thin xlarge bright pre-line";
// get the compliment text
const complimentText = this.randomCompliment();
const complimentText = this.getRandomCompliment();
// split it into parts on newline text
const parts = complimentText.split("\n");
// create a span to hold it all
// create a span to hold the compliment
const compliment = document.createElement("span");
// process all the parts of the compliment text
for (const part of parts) {
// create a text element for each part
compliment.appendChild(document.createTextNode(part));
// add a break `
compliment.appendChild(document.createElement("BR"));
if (part !== "") {
// create a text element for each part
compliment.appendChild(document.createTextNode(part));
// add a break
compliment.appendChild(document.createElement("BR"));
}
}
// only add compliment to wrapper if there is actual text in there
if (compliment.children.length > 0) {
// remove the last break
compliment.lastElementChild.remove();
wrapper.appendChild(compliment);
}
// remove the last break
compliment.lastElementChild.remove();
wrapper.appendChild(compliment);
return wrapper;
},
// From data currentweather set weather type
setCurrentWeatherType: function (type) {
this.currentWeatherType = type;
},
// Override notification handler.
notificationReceived: function (notification, payload, sender) {
if (notification === "CURRENTWEATHER_TYPE") {
this.setCurrentWeatherType(payload.type);
this.currentWeatherType = payload.type;
}
}
});

View file

@ -42,6 +42,14 @@ Module.register("newsfeed", {
dangerouslyDisableAutoEscaping: false
},
getUrlPrefix: function (item) {
if (item.useCorsProxy) {
return location.protocol + "//" + location.host + "/cors?url=";
} else {
return "";
}
},
// Define required scripts.
getScripts: function () {
return ["moment.js"];
@ -142,14 +150,19 @@ Module.register("newsfeed", {
sourceTitle: item.sourceTitle,
publishDate: moment(new Date(item.pubdate)).fromNow(),
title: item.title,
url: item.url,
url: this.getUrlPrefix(item) + item.url,
description: item.description,
items: items
};
},
getActiveItemURL: function () {
return typeof this.newsItems[this.activeItem].url === "string" ? this.newsItems[this.activeItem].url : this.newsItems[this.activeItem].url.href;
const item = this.newsItems[this.activeItem];
if (item) {
return typeof item.url === "string" ? this.getUrlPrefix(item) + item.url : this.getUrlPrefix(item) + item.url.href;
} else {
return "";
}
},
/**

View file

@ -18,9 +18,10 @@ const stream = require("stream");
* @param {number} reloadInterval Reload interval in milliseconds.
* @param {string} encoding Encoding of the feed.
* @param {boolean} logFeedWarnings If true log warnings when there is an error parsing a news article.
* @param {boolean} useCorsProxy If true cors proxy is used for article url's.
* @class
*/
const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings) {
const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy) {
let reloadTimer = null;
let items = [];
@ -57,7 +58,8 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
title: title,
description: description,
pubdate: pubdate,
url: url
url: url,
useCorsProxy: useCorsProxy
});
} else if (logFeedWarnings) {
Log.warn("Can't parse feed item:");

View file

@ -34,6 +34,8 @@ module.exports = NodeHelper.create({
const url = feed.url || "";
const encoding = feed.encoding || "UTF-8";
const reloadInterval = feed.reloadInterval || config.reloadInterval || 5 * 60 * 1000;
let useCorsProxy = feed.useCorsProxy;
if (useCorsProxy === undefined) useCorsProxy = true;
try {
new URL(url);
@ -46,7 +48,7 @@ module.exports = NodeHelper.create({
let fetcher;
if (typeof this.fetchers[url] === "undefined") {
Log.log("Create new newsfetcher for url: " + url + " - Interval: " + reloadInterval);
fetcher = new NewsfeedFetcher(url, reloadInterval, encoding, config.logFeedWarnings);
fetcher = new NewsfeedFetcher(url, reloadInterval, encoding, config.logFeedWarnings, useCorsProxy);
fetcher.onReceive(() => {
this.broadcastFeeds();

View file

@ -92,20 +92,18 @@ class GitHelper {
// examples for status:
// ## develop...origin/develop
// ## master...origin/master [behind 8]
status = status.match(/(?![.#])([^.]*)/g);
// ## master...origin/master [ahead 8, behind 1]
status = status.match(/## (.*)\.\.\.([^ ]*)(?: .*behind (\d+))?/);
// examples for status:
// [ ' develop', 'origin/develop', '' ]
// [ ' master', 'origin/master [behind 8]', '' ]
gitInfo.current = status[0].trim();
status = status[1].split(" ");
// examples for status:
// [ 'origin/develop' ]
// [ 'origin/master', '[behind', '8]' ]
gitInfo.tracking = status[0].trim();
// [ '## develop...origin/develop', 'develop', 'origin/develop' ]
// [ '## master...origin/master [behind 8]', 'master', 'origin/master', '8' ]
// [ '## master...origin/master [ahead 8, behind 1]', 'master', 'origin/master', '1' ]
gitInfo.current = status[1];
gitInfo.tracking = status[2];
if (status[2]) {
if (status[3]) {
// git fetch was already called before so `git status -sb` delivers already the behind number
gitInfo.behind = parseInt(status[2].substring(0, status[2].length - 1));
gitInfo.behind = parseInt(status[3]);
gitInfo.isBehindInStatus = true;
}

147
modules/default/utils.js Normal file
View file

@ -0,0 +1,147 @@
/**
* A function to make HTTP requests via the server to avoid CORS-errors.
*
* @param {string} url the url to fetch from
* @param {string} type what contenttype to expect in the response, can be "json" or "xml"
* @param {boolean} useCorsProxy A flag to indicate
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not allready contain a headers-property).
*/
async function performWebRequest(url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined) {
const request = {};
if (useCorsProxy) {
url = getCorsUrl(url, requestHeaders, expectedResponseHeaders);
} else {
request.headers = getHeadersToSend(requestHeaders);
}
const response = await fetch(url, request);
const data = await response.text();
if (type === "xml") {
return new DOMParser().parseFromString(data, "text/html");
} else {
if (!data || !data.length > 0) return undefined;
const dataResponse = JSON.parse(data);
if (!dataResponse.headers) {
dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);
}
return dataResponse;
}
}
/**
* Gets a URL that will be used when calling the CORS-method on the server.
*
* @param {string} url the url to fetch from
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @returns {string} to be used as URL when calling CORS-method on server.
*/
const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders) {
if (!url || url.length < 1) {
throw new Error(`Invalid URL: ${url}`);
} else {
let corsUrl = `${location.protocol}//${location.host}/cors?`;
const requestHeaderString = getRequestHeaderString(requestHeaders);
if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`;
const expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders);
if (requestHeaderString && expectedResponseHeadersString) {
corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`;
} else if (expectedResponseHeadersString) {
corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`;
}
if (requestHeaderString || expectedResponseHeadersString) {
return `${corsUrl}&url=${url}`;
}
return `${corsUrl}url=${url}`;
}
};
/**
* Gets the part of the CORS URL that represents the HTTP headers to send.
*
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @returns {string} to be used as request-headers component in CORS URL.
*/
const getRequestHeaderString = function (requestHeaders) {
let requestHeaderString = "";
if (requestHeaders) {
for (const header of requestHeaders) {
if (requestHeaderString.length === 0) {
requestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`;
} else {
requestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`;
}
}
return requestHeaderString;
}
return undefined;
};
/**
* Gets headers and values to attatch to the web request.
*
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @returns {object} An object specifying name and value of the headers.
*/
const getHeadersToSend = (requestHeaders) => {
const headersToSend = {};
if (requestHeaders) {
for (const header of requestHeaders) {
headersToSend[header.name] = header.value;
}
}
return headersToSend;
};
/**
* Gets the part of the CORS URL that represents the expected HTTP headers to recieve.
*
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @returns {string} to be used as the expected HTTP-headers component in CORS URL.
*/
const getExpectedResponseHeadersString = function (expectedResponseHeaders) {
let expectedResponseHeadersString = "";
if (expectedResponseHeaders) {
for (const header of expectedResponseHeaders) {
if (expectedResponseHeadersString.length === 0) {
expectedResponseHeadersString = `${header}`;
} else {
expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`;
}
}
return expectedResponseHeaders;
}
return undefined;
};
/**
* Gets the values for the expected headers from the response.
*
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @param {Response} response the HTTP response
* @returns {string} to be used as the expected HTTP-headers component in CORS URL.
*/
const getHeadersFromResponse = (expectedResponseHeaders, response) => {
const responseHeaders = [];
if (expectedResponseHeaders) {
for (const header of expectedResponseHeaders) {
const headerValue = response.headers.get(header);
responseHeaders.push({ name: header, value: headerValue });
}
}
return responseHeaders;
};
if (typeof module !== "undefined")
module.exports = {
performWebRequest
};

View file

@ -3,15 +3,7 @@
<div class="normal medium">
<span class="wi wi-strong-wind dimmed"></span>
<span>
{% if config.useBeaufort %}
{{ current.beaufortWindSpeed() | round }}
{% else %}
{% if config.useKmh %}
{{ current.kmhWindSpeed() | round }}
{% else %}
{{ current.windSpeed | round }}
{% endif %}
{% endif %}
{{ current.windSpeed | unit("wind") | round }}
{% if config.showWindDirection %}
<sup>
{% if config.showWindDirectionAsArrow %}

View file

@ -26,11 +26,6 @@ WeatherProvider.register("darksky", {
lon: 0
},
units: {
imperial: "us",
metric: "si"
},
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => {
@ -67,13 +62,12 @@ WeatherProvider.register("darksky", {
// Create a URL from the config and base URL.
getUrl() {
const units = this.units[this.config.units] || "auto";
return `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=${units}&lang=${this.config.lang}`;
return `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${this.config.lang}`;
},
// Implement WeatherDay generator.
generateWeatherDayFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const currentWeather = new WeatherObject();
currentWeather.date = moment();
currentWeather.humidity = parseFloat(currentWeatherData.currently.humidity);
@ -81,8 +75,8 @@ WeatherProvider.register("darksky", {
currentWeather.windSpeed = parseFloat(currentWeatherData.currently.windSpeed);
currentWeather.windDirection = currentWeatherData.currently.windBearing;
currentWeather.weatherType = this.convertWeatherType(currentWeatherData.currently.icon);
currentWeather.sunrise = moment(currentWeatherData.daily.data[0].sunriseTime, "X");
currentWeather.sunset = moment(currentWeatherData.daily.data[0].sunsetTime, "X");
currentWeather.sunrise = moment.unix(currentWeatherData.daily.data[0].sunriseTime);
currentWeather.sunset = moment.unix(currentWeatherData.daily.data[0].sunsetTime);
return currentWeather;
},
@ -91,9 +85,9 @@ WeatherProvider.register("darksky", {
const days = [];
for (const forecast of forecasts) {
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const weather = new WeatherObject();
weather.date = moment(forecast.time, "X");
weather.date = moment.unix(forecast.time);
weather.minTemperature = forecast.temperatureMin;
weather.maxTemperature = forecast.temperatureMax;
weather.weatherType = this.convertWeatherType(forecast.icon);

View file

@ -1,4 +1,4 @@
/* global WeatherProvider, WeatherObject */
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
@ -11,13 +11,13 @@
* https://dd.weather.gc.ca/citypage_weather/schema/
* https://eccc-msc.github.io/open-data/msc-datamart/readme_en/
*
* This module supports Canadian locations only and requires 2 additional config parms:
* This module supports Canadian locations only and requires 2 additional config parameters:
*
* siteCode - the city/town unique identifier for which weather is to be displayed. Format is 's0000000'.
*
* provCode - the 2-character province code for the selected city/town.
*
* Example: for Toronto, Ontario, the following parms would be used
* Example: for Toronto, Ontario, the following parameters would be used
*
* siteCode: 's0000458',
* provCode: 'ON'
@ -64,17 +64,13 @@ WeatherProvider.register("envcanada", {
start: function () {
Log.info(`Weather provider: ${this.providerName} started.`);
this.setFetchedLocation(this.config.location);
// Ensure kmH are ignored since these are custom-handled by this Provider
this.config.useKmh = false;
},
//
// Override the fetchCurrentWeather method to query EC and construct a Current weather object
//
fetchCurrentWeather() {
this.fetchData(this.getUrl(), "GET", "xml")
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
@ -94,7 +90,7 @@ WeatherProvider.register("envcanada", {
// Override the fetchWeatherForecast method to query EC and construct Forecast weather objects
//
fetchWeatherForecast() {
this.fetchData(this.getUrl(), "GET", "xml")
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
@ -114,7 +110,7 @@ WeatherProvider.register("envcanada", {
// Override the fetchWeatherHourly method to query EC and construct Forecast weather objects
//
fetchWeatherHourly() {
this.fetchData(this.getUrl(), "GET", "xml")
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
@ -137,8 +133,8 @@ WeatherProvider.register("envcanada", {
//////////////////////////////////////////////////////////////////////////////////
//
// Build the EC URL based on the Site Code and Province Code specified in the config parms. Note that the
// URL defaults to the Englsih version simply because there is no language dependancy in the data
// 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.
//
getUrl() {
@ -150,7 +146,7 @@ WeatherProvider.register("envcanada", {
//
generateWeatherObjectFromCurrentWeather(ECdoc) {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
const currentWeather = new WeatherObject();
// There are instances where EC will update weather data and current temperature will not be
// provided. While this is a defect in the EC systems, we need to accommodate to avoid a current temp
@ -161,13 +157,13 @@ WeatherProvider.register("envcanada", {
// EC finds no current temp. In this scenario, MM will end up displaying a current temp of null;
if (ECdoc.querySelector("siteData currentConditions temperature").textContent) {
currentWeather.temperature = this.convertTemp(ECdoc.querySelector("siteData currentConditions temperature").textContent);
currentWeather.temperature = ECdoc.querySelector("siteData currentConditions temperature").textContent;
this.cacheCurrentTemp = currentWeather.temperature;
} else {
currentWeather.temperature = this.cacheCurrentTemp;
}
currentWeather.windSpeed = this.convertWind(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
currentWeather.windDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent;
@ -190,11 +186,11 @@ WeatherProvider.register("envcanada", {
currentWeather.feelsLikeTemp = currentWeather.temperature;
if (ECdoc.querySelector("siteData currentConditions windChill")) {
currentWeather.feelsLikeTemp = this.convertTemp(ECdoc.querySelector("siteData currentConditions windChill").textContent);
currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions windChill").textContent;
}
if (ECdoc.querySelector("siteData currentConditions humidex")) {
currentWeather.feelsLikeTemp = this.convertTemp(ECdoc.querySelector("siteData currentConditions humidex").textContent);
currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions humidex").textContent;
}
}
@ -225,7 +221,7 @@ WeatherProvider.register("envcanada", {
const days = [];
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
const weather = new WeatherObject();
const foreBaseDates = ECdoc.querySelectorAll("siteData forecastGroup dateTime");
const baseDate = foreBaseDates[1].querySelector("timeStamp").textContent;
@ -326,7 +322,7 @@ WeatherProvider.register("envcanada", {
days.push(weather);
//
// Now do the the rest of the forecast starting at nextDay. We will process each day using 2 EC
// Now do the rest of the forecast starting at nextDay. We will process each day using 2 EC
// forecast Elements. This will address the fact that the EC forecast always includes Today and
// Tonight for each day. This is why we iterate through the forecast by a a count of 2, with each
// iteration looking at the current Element and the next Element.
@ -335,12 +331,12 @@ WeatherProvider.register("envcanada", {
let lastDate = moment(baseDate, "YYYYMMDDhhmmss");
for (let stepDay = nextDay; stepDay < lastDay; stepDay += 2) {
let weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
let weather = new WeatherObject();
// Add 1 to the date to reflect the current forecast day we are building
lastDate = lastDate.add(1, "day");
weather.date = moment(lastDate, "X");
weather.date = moment.unix(lastDate);
// Capture the temperatures for the current Element and the next Element in order to set
// the Min and Max temperatures for the forecast
@ -389,17 +385,17 @@ WeatherProvider.register("envcanada", {
const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast");
for (let stepHour = 0; stepHour < 24; stepHour += 1) {
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
const weather = new WeatherObject();
// Determine local time by applying UTC offset to the forecast timestamp
const foreTime = moment(hourGroup[stepHour].getAttribute("dateTimeUTC"), "YYYYMMDDhhmmss");
const currTime = foreTime.add(hourOffset, "hours");
weather.date = moment(currTime, "X");
weather.date = moment.unix(currTime);
// Capture the temperature
weather.temperature = this.convertTemp(hourGroup[stepHour].querySelector("temperature").textContent);
weather.temperature = hourGroup[stepHour].querySelector("temperature").textContent;
// Capture Likelihood of Precipitation (LOP) and unit-of-measure values
@ -450,7 +446,7 @@ WeatherProvider.register("envcanada", {
weather.minTemperature = this.todayTempCacheMin;
weather.maxTemperature = this.todayTempCacheMax;
} else {
weather.minTemperature = this.convertTemp(currentTemp);
weather.minTemperature = currentTemp;
weather.maxTemperature = weather.minTemperature;
}
}
@ -463,14 +459,14 @@ WeatherProvider.register("envcanada", {
//
if (todayClass === "low") {
weather.minTemperature = this.convertTemp(todayTemp);
weather.minTemperature = todayTemp;
if (today === 0 && fullDay === true) {
this.todayTempCacheMin = weather.minTemperature;
}
}
if (todayClass === "high") {
weather.maxTemperature = this.convertTemp(todayTemp);
weather.maxTemperature = todayTemp;
if (today === 0 && fullDay === true) {
this.todayTempCacheMax = weather.maxTemperature;
}
@ -482,11 +478,11 @@ WeatherProvider.register("envcanada", {
if (fullDay === true) {
if (nextClass === "low") {
weather.minTemperature = this.convertTemp(nextTemp);
weather.minTemperature = nextTemp;
}
if (nextClass === "high") {
weather.maxTemperature = this.convertTemp(nextTemp);
weather.maxTemperature = nextTemp;
}
}
},
@ -536,31 +532,6 @@ WeatherProvider.register("envcanada", {
}
},
//
// Unit conversions
//
//
// Convert C to F temps
//
convertTemp(temp) {
if (this.config.tempUnits === "imperial") {
return 1.8 * temp + 32;
} else {
return temp;
}
},
//
// Convert km/h to mph
//
convertWind(kilo) {
if (this.config.windUnits === "imperial") {
return kilo / 1.609344;
} else {
return kilo;
}
},
//
// Convert the icons to a more usable name.
//

View file

@ -0,0 +1,537 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
* Provider: Open-Meteo
*
* By Andrés Vanegas
* MIT Licensed
*
* This class is a provider for Open-Meteo, based on Andrew Pometti's class
* for Weatherbit.
*/
// https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api
const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client";
const OPEN_METEO_BASE = "https://api.open-meteo.com/v1";
WeatherProvider.register("openmeteo", {
// Set the name of the provider.
// Not strictly required, but helps for debugging.
providerName: "Open-Meteo",
// Set the default config properties that is specific to this provider
defaults: {
apiBase: OPEN_METEO_BASE,
lat: 0,
lon: 0,
past_days: 0,
type: "current"
},
// https://open-meteo.com/en/docs
hourlyParams: [
// Air temperature at 2 meters above ground
"temperature_2m",
// Relative humidity at 2 meters above ground
"relativehumidity_2m",
// Dew point temperature at 2 meters above ground
"dewpoint_2m",
// Apparent temperature is the perceived feels-like temperature combining wind chill factor, relative humidity and solar radiation
"apparent_temperature",
// Atmospheric air pressure reduced to mean sea level (msl) or pressure at surface. Typically pressure on mean sea level is used in meteorology. Surface pressure gets lower with increasing elevation.
"pressure_msl",
"surface_pressure",
// Total cloud cover as an area fraction
"cloudcover",
// Low level clouds and fog up to 3 km altitude
"cloudcover_low",
// Mid level clouds from 3 to 8 km altitude
"cloudcover_mid",
// High level clouds from 8 km altitude
"cloudcover_high",
// Wind speed at 10, 80, 120 or 180 meters above ground. Wind speed on 10 meters is the standard level.
"windspeed_10m",
"windspeed_80m",
"windspeed_120m",
"windspeed_180m",
// Wind direction at 10, 80, 120 or 180 meters above ground
"winddirection_10m",
"winddirection_80m",
"winddirection_120m",
"winddirection_180m",
// Gusts at 10 meters above ground as a maximum of the preceding hour
"windgusts_10m",
// Shortwave solar radiation as average of the preceding hour. This is equal to the total global horizontal irradiation
"shortwave_radiation",
// Direct solar radiation as average of the preceding hour on the horizontal plane and the normal plane (perpendicular to the sun)
"direct_radiation",
"direct_normal_irradiance",
// Diffuse solar radiation as average of the preceding hour
"diffuse_radiation",
// Vapor Pressure Deificit (VPD) in kilopascal (kPa). For high VPD (>1.6), water transpiration of plants increases. For low VPD (<0.4), transpiration decreases
"vapor_pressure_deficit",
// Evapotranspration from land surface and plants that weather models assumes for this location. Available soil water is considered. 1 mm evapotranspiration per hour equals 1 liter of water per spare meter.
"evapotranspiration",
// ET₀ Reference Evapotranspiration of a well watered grass field. Based on FAO-56 Penman-Monteith equations ET₀ is calculated from temperature, wind speed, humidity and solar radiation. Unlimited soil water is assumed. ET₀ is commonly used to estimate the required irrigation for plants.
"et0_fao_evapotranspiration",
// Total precipitation (rain, showers, snow) sum of the preceding hour
"precipitation",
// Snowfall amount of the preceding hour in centimeters. For the water equivalent in millimeter, divide by 7. E.g. 7 cm snow = 10 mm precipitation water equivalent
"snowfall",
// Rain from large scale weather systems of the preceding hour in millimeter
"rain",
// Showers from convective precipitation in millimeters from the preceding hour
"showers",
// Weather condition as a numeric code. Follow WMO weather interpretation codes.
"weathercode",
// Snow depth on the ground
"snow_depth",
// Altitude above sea level of the 0°C level
"freezinglevel_height",
// Temperature in the soil at 0, 6, 18 and 54 cm depths. 0 cm is the surface temperature on land or water surface temperature on water.
"soil_temperature_0cm",
"soil_temperature_6cm",
"soil_temperature_18cm",
"soil_temperature_54cm",
// Average soil water content as volumetric mixing ratio at 0-1, 1-3, 3-9, 9-27 and 27-81 cm depths.
"soil_moisture_0_1cm",
"soil_moisture_1_3cm",
"soil_moisture_3_9cm",
"soil_moisture_9_27cm",
"soil_moisture_27_81cm"
],
dailyParams: [
// Maximum and minimum daily air temperature at 2 meters above ground
"temperature_2m_max",
"temperature_2m_min",
// Maximum and minimum daily apparent temperature
"apparent_temperature_min",
"apparent_temperature_max",
// Sum of daily precipitation (including rain, showers and snowfall)
"precipitation_sum",
// Sum of daily rain
"rain_sum",
// Sum of daily showers
"showers_sum",
// Sum of daily snowfall
"snowfall_sum",
// The number of hours with rain
"precipitation_hours",
// The most severe weather condition on a given day
"weathercode",
// Sun rise and set times
"sunrise",
"sunset",
// Maximum wind speed and gusts on a day
"windspeed_10m_max",
"windgusts_10m_max",
// Dominant wind direction
"winddirection_10m_dominant",
// The sum of solar radiation on a given day in Megajoules
"shortwave_radiation_sum",
// Daily sum of ET₀ Reference Evapotranspiration of a well watered grass field
"et0_fao_evapotranspiration"
],
fetchedLocation: function () {
return this.fetchedLocationName || "";
},
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => this.parseWeatherApiResponse(data))
.then((parsedData) => {
if (!parsedData) {
// No usable data?
return;
}
const currentWeather = this.generateWeatherDayFromCurrentWeather(parsedData);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
fetchWeatherForecast() {
this.fetchData(this.getUrl())
.then((data) => this.parseWeatherApiResponse(data))
.then((parsedData) => {
if (!parsedData) {
// No usable data?
return;
}
const dailyForecast = this.generateWeatherObjectsFromForecast(parsedData);
this.setWeatherForecast(dailyForecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
fetchWeatherHourly() {
this.fetchData(this.getUrl())
.then((data) => this.parseWeatherApiResponse(data))
.then((parsedData) => {
if (!parsedData) {
// No usable data?
return;
}
const hourlyForecast = this.generateWeatherObjectsFromHourly(parsedData);
this.setWeatherHourly(hourlyForecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
/**
* Overrides method for setting config to check if endpoint is correct for hourly
*
* @param {object} config The configuration object
*/
setConfig(config) {
this.config = {
lang: config.lang ?? "en",
...this.defaults,
...config
};
// Set properly maxNumberOfDays and max Entries properties according to config and value ranges allowed in the documentation
const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0;
if (this.config.hasOwnProperty("maxNumberOfDays") && !isNaN(parseFloat(this.config.maxNumberOfDays))) {
const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0;
this.config.maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit));
this.config.maxNumberOfDays = Math.ceil(this.config.maxEntries / Math.max(1, daysFactor));
}
this.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit));
if (!this.config.type) {
Log.error("type not configured and could not resolve it");
}
this.fetchLocation();
},
// Generate valid query params to perform the request
getQueryParameters() {
let params = {
latitude: this.config.lat,
longitude: this.config.lon,
timeformat: "unixtime",
timezone: "auto",
past_days: this.config.past_days ?? 0,
daily: this.dailyParams,
hourly: this.hourlyParams,
// Fixed units as metric
temperature_unit: "celsius",
windspeed_unit: "kmh",
precipitation_unit: "mm"
};
const startDate = moment().startOf("day");
const endDate = moment(startDate)
.add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), "days")
.endOf("day");
params["start_date"] = startDate.format("YYYY-MM-DD");
switch (this.config.type) {
case "hourly":
case "daily":
case "forecast":
params["end_date"] = endDate.format("YYYY-MM-DD");
break;
case "current":
params["current_weather"] = true;
params["end_date"] = params["start_date"];
break;
default:
// Failsafe
return "";
}
return Object.keys(params)
.filter((key) => (params[key] ? true : false))
.map((key) => {
switch (key) {
case "hourly":
case "daily":
return encodeURIComponent(key) + "=" + params[key].join(",");
default:
return encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
}
})
.join("&");
},
// Create a URL from the config and base URL.
getUrl() {
return `${this.config.apiBase}/forecast?${this.getQueryParameters()}`;
},
// Transpose hourly and daily data matrices
transposeDataMatrix(data) {
return data.time.map((_, index) =>
Object.keys(data).reduce((row, key) => {
return {
...row,
// Parse time values as momentjs instances
[key]: ["time", "sunrise", "sunset"].includes(key) ? moment.unix(data[key][index]) : data[key][index]
};
}, {})
);
},
// Sanitize and validate API response
parseWeatherApiResponse(data) {
const validByType = {
current: data.current_weather && data.current_weather.time,
hourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0,
daily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0
};
// backwards compatibility
const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type;
if (!validByType[type]) return;
switch (type) {
case "current":
if (!validByType.daily && !validByType.hourly) {
return;
}
break;
case "hourly":
case "daily":
break;
default:
return;
}
for (const key of ["hourly", "daily"]) {
if (typeof data[key] === "object") {
data[key] = this.transposeDataMatrix(data[key]);
}
}
if (data.current_weather) {
data.current_weather.time = moment.unix(data.current_weather.time);
}
return data;
},
// Reverse geocoding from latitude and longitude provided
fetchLocation() {
this.fetchData(`${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang}`)
.then((data) => {
if (!data || !data.city) {
// No usable data?
return;
}
this.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`;
})
.catch((request) => {
Log.error("Could not load data ... ", request);
});
},
// Implement WeatherDay generator.
generateWeatherDayFromCurrentWeather(weather) {
/**
* Since some units comes from API response "splitted" into daily, hourly and current_weather
* every time you request it, you have to ensure to get the data from the right place every time.
* For the current weather case, the response have the following structure (after transposing):
* ```
* {
* current_weather: { ...<some current weather here> },
* hourly: [
* 0: {...<data for hour zero here> },
* 1: {...<data for hour one here> },
* ...
* ],
* daily: [
* {...<summary data for current day here> },
* ]
* }
* ```
* Some data should be returned from `hourly` array data when the index matches the current hour,
* some data from the first and only one object received in `daily` array and some from the
* `current_weather` object.
*/
const h = moment().hour();
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
currentWeather.date = weather.current_weather.time;
currentWeather.windSpeed = weather.current_weather.windspeed;
currentWeather.windDirection = weather.current_weather.winddirection;
currentWeather.sunrise = weather.daily[0].sunrise;
currentWeather.sunset = weather.daily[0].sunset;
currentWeather.temperature = parseFloat(weather.current_weather.temperature);
currentWeather.minTemperature = parseFloat(weather.daily[0].temperature_2m_min);
currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max);
currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime());
currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m);
currentWeather.rain = parseFloat(weather.hourly[h].rain);
currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10);
currentWeather.precipitation = parseFloat(weather.hourly[h].precipitation);
return currentWeather;
},
// Implement WeatherForecast generator.
generateWeatherObjectsFromForecast(weathers) {
const days = [];
weathers.daily.forEach((weather, i) => {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
currentWeather.date = weather.time;
currentWeather.windSpeed = weather.windspeed_10m_max;
currentWeather.windDirection = weather.winddirection_10m_dominant;
currentWeather.sunrise = weather.sunrise;
currentWeather.sunset = weather.sunset;
currentWeather.temperature = parseFloat((weather.apparent_temperature_max + weather.apparent_temperature_min) / 2);
currentWeather.minTemperature = parseFloat(weather.apparent_temperature_min);
currentWeather.maxTemperature = parseFloat(weather.apparent_temperature_max);
currentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime());
currentWeather.rain = parseFloat(weather.rain_sum);
currentWeather.snow = parseFloat(weather.snowfall_sum * 10);
currentWeather.precipitation = parseFloat(weather.precipitation_sum);
days.push(currentWeather);
});
return days;
},
// Implement WeatherHourly generator.
generateWeatherObjectsFromHourly(weathers) {
const hours = [];
const now = moment();
weathers.hourly.forEach((weather, i) => {
if ((hours.length === 0 && weather.time.hour() <= now.hour()) || hours.length >= this.config.maxEntries) {
return;
}
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
const h = Math.ceil((i + 1) / 24) - 1;
currentWeather.date = weather.time;
currentWeather.windSpeed = weather.windspeed_10m;
currentWeather.windDirection = weather.winddirection_10m;
currentWeather.sunrise = weathers.daily[h].sunrise;
currentWeather.sunset = weathers.daily[h].sunset;
currentWeather.temperature = parseFloat(weather.apparent_temperature);
currentWeather.minTemperature = parseFloat(weathers.daily[h].apparent_temperature_min);
currentWeather.maxTemperature = parseFloat(weathers.daily[h].apparent_temperature_max);
currentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime());
currentWeather.humidity = parseFloat(weather.relativehumidity_2m);
currentWeather.rain = parseFloat(weather.rain);
currentWeather.snow = parseFloat(weather.snowfall * 10);
currentWeather.precipitation = parseFloat(weather.precipitation);
hours.push(currentWeather);
});
return hours;
},
// Map icons from Dark Sky to our icons.
convertWeatherType(weathercode, isDayTime) {
const weatherConditions = {
0: "clear",
1: "mainly-clear",
2: "partly-cloudy",
3: "overcast",
45: "fog",
48: "depositing-rime-fog",
51: "drizzle-light-intensity",
53: "drizzle-moderate-intensity",
55: "drizzle-dense-intensity",
56: "freezing-drizzle-light-intensity",
57: "freezing-drizzle-dense-intensity",
61: "rain-slight-intensity",
63: "rain-moderate-intensity",
65: "rain-heavy-intensity",
66: "freezing-rain-light-heavy-intensity",
67: "freezing-rain-heavy-intensity",
71: "snow-fall-slight-intensity",
73: "snow-fall-moderate-intensity",
75: "snow-fall-heavy-intensity",
77: "snow-grains",
80: "rain-showers-slight",
81: "rain-showers-moderate",
82: "rain-showers-violent",
85: "snow-showers-slight",
86: "snow-showers-heavy",
95: "thunderstorm",
96: "thunderstorm-slight-hail",
99: "thunderstorm-heavy-hail"
};
if (!Object.keys(weatherConditions).includes(`${weathercode}`)) return null;
switch (weatherConditions[`${weathercode}`]) {
case "clear":
return isDayTime ? "day-sunny" : "night-clear";
case "mainly-clear":
case "partly-cloudy":
return isDayTime ? "day-cloudy" : "night-alt-cloudy";
case "overcast":
return isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy";
case "fog":
case "depositing-rime-fog":
return isDayTime ? "day-fog" : "night-fog";
case "drizzle-light-intensity":
case "rain-slight-intensity":
case "rain-showers-slight":
return isDayTime ? "day-sprinkle" : "night-sprinkle";
case "drizzle-moderate-intensity":
case "rain-moderate-intensity":
case "rain-showers-moderate":
return isDayTime ? "day-showers" : "night-showers";
case "drizzle-dense-intensity":
case "rain-heavy-intensity":
case "rain-showers-violent":
return isDayTime ? "day-thunderstorm" : "night-thunderstorm";
case "freezing-rain-light-intensity":
return isDayTime ? "day-rain-mix" : "night-rain-mix";
case "freezing-drizzle-light-intensity":
case "freezing-drizzle-dense-intensity":
return "snowflake-cold";
case "snow-grains":
return isDayTime ? "day-sleet" : "night-sleet";
case "snow-fall-slight-intensity":
case "snow-fall-moderate-intensity":
return isDayTime ? "day-snow-wind" : "night-snow-wind";
case "snow-fall-heavy-intensity":
case "freezing-rain-heavy-intensity":
return isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm";
case "snow-showers-slight":
case "snow-showers-heavy":
return isDayTime ? "day-rain-mix" : "night-rain-mix";
case "thunderstorm":
return isDayTime ? "day-thunderstorm" : "night-thunderstorm";
case "thunderstorm-slight-hail":
return isDayTime ? "day-sleet" : "night-sleet";
case "thunderstorm-heavy-hail":
return isDayTime ? "day-sleet-storm" : "night-sleet-storm";
default:
return "na";
}
},
// Define required scripts.
getScripts: function () {
return ["moment.js"];
}
});

View file

@ -21,7 +21,7 @@ WeatherProvider.register("openweathermap", {
weatherEndpoint: "", // can be "onecall", "forecast" or "weather" (for current)
locationID: false,
location: false,
lat: 0, // the onecall endpoint needs lat / lon values, it doesn'T support the locationId
lat: 0, // the onecall endpoint needs lat / lon values, it doesn't support the locationId
lon: 0,
apiKey: ""
},
@ -30,14 +30,14 @@ WeatherProvider.register("openweathermap", {
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => {
let currentWeather;
if (this.config.weatherEndpoint === "/onecall") {
const weatherData = this.generateWeatherObjectsFromOnecall(data);
this.setCurrentWeather(weatherData.current);
currentWeather = this.generateWeatherObjectsFromOnecall(data).current;
this.setFetchedLocation(`${data.timezone}`);
} else {
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
}
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
@ -49,15 +49,17 @@ WeatherProvider.register("openweathermap", {
fetchWeatherForecast() {
this.fetchData(this.getUrl())
.then((data) => {
let forecast;
let location;
if (this.config.weatherEndpoint === "/onecall") {
const weatherData = this.generateWeatherObjectsFromOnecall(data);
this.setWeatherForecast(weatherData.days);
this.setFetchedLocation(`${data.timezone}`);
forecast = this.generateWeatherObjectsFromOnecall(data).days;
location = `${data.timezone}`;
} else {
const forecast = this.generateWeatherObjectsFromForecast(data.list);
this.setWeatherForecast(forecast);
this.setFetchedLocation(`${data.city.name}, ${data.city.country}`);
forecast = this.generateWeatherObjectsFromForecast(data.list);
location = `${data.city.name}, ${data.city.country}`;
}
this.setWeatherForecast(forecast);
this.setFetchedLocation(location);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
@ -123,16 +125,17 @@ WeatherProvider.register("openweathermap", {
* Generate a WeatherObject based on currentWeatherInformation
*/
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const currentWeather = new WeatherObject();
currentWeather.date = moment.unix(currentWeatherData.dt);
currentWeather.humidity = currentWeatherData.main.humidity;
currentWeather.temperature = currentWeatherData.main.temp;
currentWeather.feelsLikeTemp = currentWeatherData.main.feels_like;
currentWeather.windSpeed = currentWeatherData.wind.speed;
currentWeather.windDirection = currentWeatherData.wind.deg;
currentWeather.weatherType = this.convertWeatherType(currentWeatherData.weather[0].icon);
currentWeather.sunrise = moment(currentWeatherData.sys.sunrise, "X");
currentWeather.sunset = moment(currentWeatherData.sys.sunset, "X");
currentWeather.sunrise = moment.unix(currentWeatherData.sys.sunrise);
currentWeather.sunset = moment.unix(currentWeatherData.sys.sunset);
return currentWeather;
},
@ -147,8 +150,7 @@ WeatherProvider.register("openweathermap", {
return this.fetchForecastDaily(forecasts);
}
// if weatherEndpoint does not match forecast or forecast/daily, what should be returned?
const days = [new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh)];
return days;
return [new WeatherObject()];
},
/*
@ -159,8 +161,7 @@ WeatherProvider.register("openweathermap", {
return this.fetchOnecall(data);
}
// if weatherEndpoint does not match onecall, what should be returned?
const weatherData = { current: new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh), hours: [], days: [] };
return weatherData;
return { current: new WeatherObject(), hours: [], days: [] };
},
/*
@ -176,10 +177,10 @@ WeatherProvider.register("openweathermap", {
let snow = 0;
// variable for date
let date = "";
let weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
let weather = new WeatherObject();
for (const forecast of forecasts) {
if (date !== moment(forecast.dt, "X").format("YYYY-MM-DD")) {
if (date !== moment.unix(forecast.dt).format("YYYY-MM-DD")) {
// calculate minimum/maximum temperature, specify rain amount
weather.minTemperature = Math.min.apply(null, minTemp);
weather.maxTemperature = Math.max.apply(null, maxTemp);
@ -189,7 +190,7 @@ WeatherProvider.register("openweathermap", {
// push weather information to days array
days.push(weather);
// create new weather-object
weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
weather = new WeatherObject();
minTemp = [];
maxTemp = [];
@ -197,16 +198,16 @@ WeatherProvider.register("openweathermap", {
snow = 0;
// set new date
date = moment(forecast.dt, "X").format("YYYY-MM-DD");
date = moment.unix(forecast.dt).format("YYYY-MM-DD");
// specify date
weather.date = moment(forecast.dt, "X");
weather.date = moment.unix(forecast.dt);
// If the first value of today is later than 17:00, we have an icon at least!
weather.weatherType = this.convertWeatherType(forecast.weather[0].icon);
}
if (moment(forecast.dt, "X").format("H") >= 8 && moment(forecast.dt, "X").format("H") <= 17) {
if (moment.unix(forecast.dt).format("H") >= 8 && moment.unix(forecast.dt).format("H") <= 17) {
weather.weatherType = this.convertWeatherType(forecast.weather[0].icon);
}
@ -252,9 +253,9 @@ WeatherProvider.register("openweathermap", {
const days = [];
for (const forecast of forecasts) {
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const weather = new WeatherObject();
weather.date = moment(forecast.dt, "X");
weather.date = moment.unix(forecast.dt);
weather.minTemperature = forecast.temp.min;
weather.maxTemperature = forecast.temp.max;
weather.weatherType = this.convertWeatherType(forecast.weather[0].icon);
@ -298,13 +299,13 @@ WeatherProvider.register("openweathermap", {
let precip = false;
// get current weather, if requested
const current = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const current = new WeatherObject();
if (data.hasOwnProperty("current")) {
current.date = moment(data.current.dt, "X").utcOffset(data.timezone_offset / 60);
current.date = moment.unix(data.current.dt).utcOffset(data.timezone_offset / 60);
current.windSpeed = data.current.wind_speed;
current.windDirection = data.current.wind_deg;
current.sunrise = moment(data.current.sunrise, "X").utcOffset(data.timezone_offset / 60);
current.sunset = moment(data.current.sunset, "X").utcOffset(data.timezone_offset / 60);
current.sunrise = moment.unix(data.current.sunrise).utcOffset(data.timezone_offset / 60);
current.sunset = moment.unix(data.current.sunset).utcOffset(data.timezone_offset / 60);
current.temperature = data.current.temp;
current.weatherType = this.convertWeatherType(data.current.weather[0].icon);
current.humidity = data.current.humidity;
@ -330,14 +331,13 @@ WeatherProvider.register("openweathermap", {
current.feelsLikeTemp = data.current.feels_like;
}
let weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
let weather = new WeatherObject();
// get hourly weather, if requested
const hours = [];
if (data.hasOwnProperty("hourly")) {
for (const hour of data.hourly) {
weather.date = moment(hour.dt, "X").utcOffset(data.timezone_offset / 60);
// weather.date = moment(hour.dt, "X").utcOffset(data.timezone_offset/60).format(onecallDailyFormat+","+onecallHourlyFormat);
weather.date = moment.unix(hour.dt).utcOffset(data.timezone_offset / 60);
weather.temperature = hour.temp;
weather.feelsLikeTemp = hour.feels_like;
weather.humidity = hour.humidity;
@ -366,7 +366,7 @@ WeatherProvider.register("openweathermap", {
}
hours.push(weather);
weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
weather = new WeatherObject();
}
}
@ -374,9 +374,9 @@ WeatherProvider.register("openweathermap", {
const days = [];
if (data.hasOwnProperty("daily")) {
for (const day of data.daily) {
weather.date = moment(day.dt, "X").utcOffset(data.timezone_offset / 60);
weather.sunrise = moment(day.sunrise, "X").utcOffset(data.timezone_offset / 60);
weather.sunset = moment(day.sunset, "X").utcOffset(data.timezone_offset / 60);
weather.date = moment.unix(day.dt).utcOffset(data.timezone_offset / 60);
weather.sunrise = moment.unix(day.sunrise).utcOffset(data.timezone_offset / 60);
weather.sunset = moment.unix(day.sunset).utcOffset(data.timezone_offset / 60);
weather.minTemperature = day.temp.min;
weather.maxTemperature = day.temp.max;
weather.humidity = day.humidity;
@ -405,7 +405,7 @@ WeatherProvider.register("openweathermap", {
}
days.push(weather);
weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
weather = new WeatherObject();
}
}
@ -474,7 +474,7 @@ WeatherProvider.register("openweathermap", {
return;
}
params += "&units=" + this.config.units;
params += "&units=metric"; // WeatherProviders should use metric internally and use the units only for when displaying data
params += "&lang=" + this.config.lang;
params += "&APPID=" + this.config.apiKey;

View file

@ -15,8 +15,8 @@ WeatherProvider.register("smhi", {
// Set the default config properties that is specific to this provider
defaults: {
lat: 0,
lon: 0,
lat: 0, // Cant have more than 6 digits
lon: 0, // Cant have more than 6 digits
precipitationValue: "pmedian",
location: false
},
@ -75,7 +75,7 @@ WeatherProvider.register("smhi", {
setConfig(config) {
this.config = config;
if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) {
console.log("invalid or not set: " + config.precipitationValue);
Log.log("invalid or not set: " + config.precipitationValue);
config.precipitationValue = this.defaults.precipitationValue;
}
},
@ -104,8 +104,12 @@ WeatherProvider.register("smhi", {
* @returns {string} the url for the specified coordinates
*/
getURL() {
let lon = this.config.lon;
let lat = this.config.lat;
const formatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 6,
maximumFractionDigits: 6
});
const lon = formatter.format(this.config.lon);
const lat = formatter.format(this.config.lat);
return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`;
},
@ -134,8 +138,7 @@ WeatherProvider.register("smhi", {
* @returns {WeatherObject} The converted weatherdata at the specified location
*/
convertWeatherDataToObject(weatherData, coordinates) {
// Weather data is only for Sweden and nobody in Sweden would use imperial
let currentWeather = new WeatherObject("metric", "metric", "metric");
let currentWeather = new WeatherObject();
currentWeather.date = moment(weatherData.validTime);
currentWeather.updateSunTime(coordinates.lat, coordinates.lon);
@ -144,7 +147,7 @@ WeatherProvider.register("smhi", {
currentWeather.windSpeed = this.paramValue(weatherData, "ws");
currentWeather.windDirection = this.paramValue(weatherData, "wd");
currentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, "Wsymb2"), currentWeather.isDayTime());
currentWeather.feelsLikeTemp = this.calculateAT(weatherData);
currentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData);
// Determine the precipitation amount and category and update the
// weatherObject with it, the valuetype to use can be configured or uses
@ -174,7 +177,7 @@ WeatherProvider.register("smhi", {
},
/**
* Takes all of the data points and converts it to one WeatherObject per day.
* Takes all the data points and converts it to one WeatherObject per day.
*
* @param {object[]} allWeatherData Array of weatherdata
* @param {object} coordinates Coordinates of the locations of the weather
@ -191,7 +194,7 @@ WeatherProvider.register("smhi", {
for (const weatherObject of allWeatherObjects) {
//If its the first object or if a day/hour change we need to reset the summary object
if (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) {
currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
currentWeather = new WeatherObject();
dayWeatherTypes = [];
currentWeather.temperature = weatherObject.temperature;
currentWeather.date = weatherObject.date;
@ -203,7 +206,7 @@ WeatherProvider.register("smhi", {
result.push(currentWeather);
}
//Keep track of what icons has been used for each hour of daytime and use the middle one for the forecast
//Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast
if (weatherObject.isDayTime()) {
dayWeatherTypes.push(weatherObject.weatherType);
}
@ -271,7 +274,7 @@ WeatherProvider.register("smhi", {
/**
* Map the icon value from SMHI to an icon that MagicMirror² understands.
* Uses different icons depending if its daytime or nighttime.
* Uses different icons depending on if its daytime or nighttime.
* SMHI's description of what the numeric value means is the comment after the case.
*
* @param {number} input The SMHI icon value

View file

@ -1,4 +1,4 @@
/* global WeatherProvider, WeatherObject */
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
@ -21,11 +21,6 @@ WeatherProvider.register("ukmetoffice", {
apiKey: ""
},
units: {
imperial: "us",
metric: "si"
},
// Overwrite the fetchCurrentWeather method.
fetchCurrentWeather() {
this.fetchData(this.getUrl("3hourly"))
@ -80,7 +75,7 @@ WeatherProvider.register("ukmetoffice", {
* Generate a WeatherObject based on currentWeatherInformation
*/
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const currentWeather = new WeatherObject();
const location = currentWeatherData.SiteRep.DV.Location;
// data times are always UTC
@ -103,11 +98,11 @@ WeatherProvider.register("ukmetoffice", {
if (timeInMins >= p && timeInMins - 180 < p) {
// finally got the one we want, so populate weather object
currentWeather.humidity = rep.H;
currentWeather.temperature = this.convertTemp(rep.T);
currentWeather.feelsLikeTemp = this.convertTemp(rep.F);
currentWeather.temperature = rep.T;
currentWeather.feelsLikeTemp = rep.F;
currentWeather.precipitation = parseInt(rep.Pp);
currentWeather.windSpeed = this.convertWindSpeed(rep.S);
currentWeather.windDirection = this.convertWindDirection(rep.D);
currentWeather.windSpeed = WeatherUtils.convertWindToMetric(rep.S);
currentWeather.windDirection = WeatherUtils.convertWindDirection(rep.D);
currentWeather.weatherType = this.convertWeatherType(rep.W);
}
}
@ -130,7 +125,7 @@ WeatherProvider.register("ukmetoffice", {
// loop round the (5) periods getting the data
// for each period array, Day is [0], Night is [1]
for (const period of forecasts.SiteRep.DV.Location.Period) {
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const weather = new WeatherObject();
// data times are always UTC
const dateStr = period.value;
@ -140,8 +135,8 @@ WeatherProvider.register("ukmetoffice", {
if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) {
// populate the weather object
weather.date = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD");
weather.minTemperature = this.convertTemp(period.Rep[1].Nm);
weather.maxTemperature = this.convertTemp(period.Rep[0].Dm);
weather.minTemperature = period.Rep[1].Nm;
weather.maxTemperature = period.Rep[0].Dm;
weather.weatherType = this.convertWeatherType(period.Rep[0].W);
weather.precipitation = parseInt(period.Rep[0].PPd);
@ -192,46 +187,6 @@ WeatherProvider.register("ukmetoffice", {
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
},
/*
* Convert temp (from degrees C) if required
*/
convertTemp(tempInC) {
return this.tempUnits === "imperial" ? (tempInC * 9) / 5 + 32 : tempInC;
},
/*
* Convert wind speed (from mph to m/s or km/h) if required
*/
convertWindSpeed(windInMph) {
return this.windUnits === "metric" ? (this.useKmh ? windInMph * 1.60934 : windInMph / 2.23694) : windInMph;
},
/*
* Convert the wind direction cardinal to value
*/
convertWindDirection(windDirection) {
const windCardinals = {
N: 0,
NNE: 22,
NE: 45,
ENE: 67,
E: 90,
ESE: 112,
SE: 135,
SSE: 157,
S: 180,
SSW: 202,
SW: 225,
WSW: 247,
W: 270,
WNW: 292,
NW: 315,
NNW: 337
};
return windCardinals.hasOwnProperty(windDirection) ? windCardinals[windDirection] : null;
},
/**
* Generates an url with api parameters based on the config.
*

View file

@ -20,11 +20,9 @@
* weatherProvider: "ukmetofficedatahub",
* apiBase: "https://api-metoffice.apiconnect.ibmcloud.com/metoffice/production/v0/forecasts/point/",
* apiKey: "[YOUR API KEY]",
* apiSecret: "[YOUR API SECRET]]",
* apiSecret: "[YOUR API SECRET]",
* lat: [LATITUDE (DECIMAL)],
* lon: [LONGITUDE (DECIMAL)],
* windUnits: "mps" | "kph" | "mph" (default)
* tempUnits: "imperial" | "metric" (default)
* lon: [LONGITUDE (DECIMAL)]
*
* At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when
* setting your update intervals. For reference, 360 requests per day is once every 4 minutes.
@ -51,8 +49,7 @@ WeatherProvider.register("ukmetofficedatahub", {
apiKey: "",
apiSecret: "",
lat: 0,
lon: 0,
windUnits: "mph"
lon: 0
},
// Build URL with query strings according to DataHub API (https://metoffice.apiconnect.ibmcloud.com/metoffice/production/api)
@ -89,7 +86,7 @@ WeatherProvider.register("ukmetofficedatahub", {
fetchCurrentWeather() {
this.fetchWeather(this.getUrl("hourly"), this.getHeaders())
.then((data) => {
// Check data is useable
// Check data is usable
if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {
// Did not receive usable new data.
// Maybe this needs a better check?
@ -109,13 +106,13 @@ WeatherProvider.register("ukmetofficedatahub", {
// Catch any error(s)
.catch((error) => Log.error("Could not load data: " + error.message))
// Let the module know there're new data available
// Let the module know there is data available
.finally(() => this.updateAvailable());
},
// Create a WeatherObject using current weather data (data for the current hour)
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const currentWeather = new WeatherObject();
// Extract the actual forecasts
let forecastDataHours = currentWeatherData.features[0].properties.timeSeries;
@ -128,19 +125,19 @@ WeatherProvider.register("ukmetofficedatahub", {
let forecastTime = moment.utc(forecastDataHours[hour].time);
if (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, "h")))) {
currentWeather.date = forecastTime;
currentWeather.windSpeed = this.convertWindSpeed(forecastDataHours[hour].windSpeed10m);
currentWeather.windSpeed = forecastDataHours[hour].windSpeed10m;
currentWeather.windDirection = forecastDataHours[hour].windDirectionFrom10m;
currentWeather.temperature = this.convertTemp(forecastDataHours[hour].screenTemperature);
currentWeather.minTemperature = this.convertTemp(forecastDataHours[hour].minScreenAirTemp);
currentWeather.maxTemperature = this.convertTemp(forecastDataHours[hour].maxScreenAirTemp);
currentWeather.temperature = forecastDataHours[hour].screenTemperature;
currentWeather.minTemperature = forecastDataHours[hour].minScreenAirTemp;
currentWeather.maxTemperature = forecastDataHours[hour].maxScreenAirTemp;
currentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode);
currentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity;
currentWeather.rain = forecastDataHours[hour].totalPrecipAmount;
currentWeather.snow = forecastDataHours[hour].totalSnowAmount;
currentWeather.precipitation = forecastDataHours[hour].probOfPrecipitation;
currentWeather.feelsLikeTemp = this.convertTemp(forecastDataHours[hour].feelsLikeTemperature);
currentWeather.feelsLikeTemp = forecastDataHours[hour].feelsLikeTemperature;
// Pass on full details so they can be used in custom templates
// Pass on full details, so they can be used in custom templates
// Note the units of the supplied data when using this (see top of file)
currentWeather.rawData = forecastDataHours[hour];
}
@ -148,7 +145,7 @@ WeatherProvider.register("ukmetofficedatahub", {
// Determine the sunrise/sunset times - (still) not supplied in UK Met Office data
// Passes {longitude, latitude} to SunCalc, could pass height to, but
// SunCalc.getTimes doesnt take that into account
// SunCalc.getTimes doesn't take that into account
currentWeather.updateSunTime(this.config.lat, this.config.lon);
return currentWeather;
@ -158,7 +155,7 @@ WeatherProvider.register("ukmetofficedatahub", {
fetchWeatherForecast() {
this.fetchWeather(this.getUrl("daily"), this.getHeaders())
.then((data) => {
// Check data is useable
// Check data is usable
if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {
// Did not receive usable new data.
// Maybe this needs a better check?
@ -178,7 +175,7 @@ WeatherProvider.register("ukmetofficedatahub", {
// Catch any error(s)
.catch((error) => Log.error("Could not load data: " + error.message))
// Let the module know there're new data available
// Let the module know there is new data available
.finally(() => this.updateAvailable());
},
@ -194,7 +191,7 @@ WeatherProvider.register("ukmetofficedatahub", {
// Go through each day in the forecasts
for (let day in forecastDataDays) {
const forecastWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const forecastWeather = new WeatherObject();
// Get date of forecast
let forecastDate = moment.utc(forecastDataDays[day].time);
@ -202,11 +199,11 @@ WeatherProvider.register("ukmetofficedatahub", {
// Check if forecast is for today or in the future (i.e., ignore yesterday's forecast)
if (forecastDate.isSameOrAfter(today)) {
forecastWeather.date = forecastDate;
forecastWeather.minTemperature = this.convertTemp(forecastDataDays[day].nightMinScreenTemperature);
forecastWeather.maxTemperature = this.convertTemp(forecastDataDays[day].dayMaxScreenTemperature);
forecastWeather.minTemperature = forecastDataDays[day].nightMinScreenTemperature;
forecastWeather.maxTemperature = forecastDataDays[day].dayMaxScreenTemperature;
// Using daytime forecast values
forecastWeather.windSpeed = this.convertWindSpeed(forecastDataDays[day].midday10MWindSpeed);
forecastWeather.windSpeed = forecastDataDays[day].midday10MWindSpeed;
forecastWeather.windDirection = forecastDataDays[day].midday10MWindDirection;
forecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode);
forecastWeather.precipitation = forecastDataDays[day].dayProbabilityOfPrecipitation;
@ -214,9 +211,9 @@ WeatherProvider.register("ukmetofficedatahub", {
forecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity;
forecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain;
forecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow;
forecastWeather.feelsLikeTemp = this.convertTemp(forecastDataDays[day].dayMaxFeelsLikeTemp);
forecastWeather.feelsLikeTemp = forecastDataDays[day].dayMaxFeelsLikeTemp;
// Pass on full details so they can be used in custom templates
// Pass on full details, so they can be used in custom templates
// Note the units of the supplied data when using this (see top of file)
forecastWeather.rawData = forecastDataDays[day];
@ -232,27 +229,6 @@ WeatherProvider.register("ukmetofficedatahub", {
this.fetchedLocationName = name;
},
// Convert temperatures to Fahrenheit (from degrees C), if required
convertTemp(tempInC) {
return this.config.tempUnits === "imperial" ? (tempInC * 9) / 5 + 32 : tempInC;
},
// Convert wind speed from metres per second
// To keep the supplied metres per second units, use "mps"
// To use kilometres per hour, use "kph"
// Else assumed imperial and the value is returned in miles per hour (a Met Office user is likely to be UK-based)
convertWindSpeed(windInMpS) {
if (this.config.windUnits === "mps") {
return windInMpS;
}
if (this.config.windUnits === "kph" || this.config.windUnits === "metric" || this.config.useKmh) {
return windInMpS * 3.6;
}
return windInMpS * 2.23694;
},
// Match the Met Office "significant weather code" to a weathericons.css icon
// Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264
// and: https://erikflowers.github.io/weather-icons/

View file

@ -23,11 +23,6 @@ WeatherProvider.register("weatherbit", {
lon: 0
},
units: {
imperial: "I",
metric: "M"
},
fetchedLocation: function () {
return this.fetchedLocationName || "";
},
@ -95,8 +90,7 @@ WeatherProvider.register("weatherbit", {
// Create a URL from the config and base URL.
getUrl() {
const units = this.units[this.config.units] || "auto";
return `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=${units}&key=${this.config.apiKey}`;
return `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`;
},
// Implement WeatherDay generator.
@ -106,9 +100,9 @@ WeatherProvider.register("weatherbit", {
let tzOffset = d.getTimezoneOffset();
tzOffset = tzOffset * -1;
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
const currentWeather = new WeatherObject();
currentWeather.date = moment(currentWeatherData.data[0].ts, "X");
currentWeather.date = moment.unix(currentWeatherData.data[0].ts);
currentWeather.humidity = parseFloat(currentWeatherData.data[0].rh);
currentWeather.temperature = parseFloat(currentWeatherData.data[0].temp);
currentWeather.windSpeed = parseFloat(currentWeatherData.data[0].wind_spd);
@ -126,7 +120,7 @@ WeatherProvider.register("weatherbit", {
const days = [];
for (const forecast of forecasts) {
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits);
const weather = new WeatherObject();
weather.date = moment(forecast.datetime, "YYYY-MM-DD");
weather.minTemperature = forecast.min_temp;

View file

@ -1,4 +1,4 @@
/* global WeatherProvider, WeatherObject */
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
@ -23,36 +23,19 @@ WeatherProvider.register("weatherflow", {
stationid: ""
},
units: {
imperial: {
temp: "f",
wind: "mph",
pressure: "hpa",
precip: "in",
distance: "mi"
},
metric: {
temp: "c",
wind: "kph",
pressure: "mb",
precip: "mm",
distance: "km"
}
},
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const currentWeather = new WeatherObject();
currentWeather.date = moment();
currentWeather.humidity = data.current_conditions.relative_humidity;
currentWeather.temperature = data.current_conditions.air_temperature;
currentWeather.windSpeed = data.current_conditions.wind_avg;
currentWeather.windSpeed = WeatherUtils.convertWindToMs(data.current_conditions.wind_avg);
currentWeather.windDirection = data.current_conditions.wind_direction;
currentWeather.weatherType = data.forecast.daily[0].icon;
currentWeather.sunrise = moment(data.forecast.daily[0].sunrise, "X");
currentWeather.sunset = moment(data.forecast.daily[0].sunset, "X");
currentWeather.sunrise = moment.unix(data.forecast.daily[0].sunrise);
currentWeather.sunset = moment.unix(data.forecast.daily[0].sunset);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
@ -67,9 +50,9 @@ WeatherProvider.register("weatherflow", {
const days = [];
for (const forecast of data.forecast.daily) {
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const weather = new WeatherObject();
weather.date = moment(forecast.day_start_local, "X");
weather.date = moment.unix(forecast.day_start_local);
weather.minTemperature = forecast.air_temp_low;
weather.maxTemperature = forecast.air_temp_high;
weather.weatherType = forecast.icon;
@ -88,22 +71,6 @@ WeatherProvider.register("weatherflow", {
// Create a URL from the config and base URL.
getUrl() {
return (
this.config.apiBase +
"better_forecast?station_id=" +
this.config.stationid +
"&units_temp=" +
this.units[this.config.units].temp +
"&units_wind=" +
this.units[this.config.units].wind +
"&units_pressure=" +
this.units[this.config.units].pressure +
"&units_precip=" +
this.units[this.config.units].precip +
"&units_distance=" +
this.units[this.config.units].distance +
"&token=" +
this.config.token
);
return `${this.config.apiBase}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`;
}
});

View file

@ -1,4 +1,4 @@
/* global WeatherProvider, WeatherObject */
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
@ -22,7 +22,6 @@ WeatherProvider.register("weathergov", {
// Set the default config properties that is specific to this provider
defaults: {
apiBase: "https://api.weather.gov/points/",
weatherEndpoint: "/forecast",
lat: 0,
lon: 0
},
@ -57,7 +56,7 @@ WeatherProvider.register("weathergov", {
// Overwrite the fetchCurrentWeather method.
fetchCurrentWeather() {
if (!this.configURLs) {
Log.info("fetch wx waiting on config URLs");
Log.info("fetchCurrentWeather: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.stationObsURL)
@ -78,7 +77,7 @@ WeatherProvider.register("weathergov", {
// Overwrite the fetchWeatherForecast method.
fetchWeatherForecast() {
if (!this.configURLs) {
Log.info("fetch wx waiting on config URLs");
Log.info("fetchWeatherForecast: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.forecastURL)
@ -96,6 +95,28 @@ WeatherProvider.register("weathergov", {
.finally(() => this.updateAvailable());
},
// Overwrite the fetchWeatherHourly method.
fetchWeatherHourly() {
if (!this.configURLs) {
Log.info("fetchWeatherHourly: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.forecastHourlyURL)
.then((data) => {
if (!data) {
// Did not receive usable new data.
// Maybe this needs a better check?
return;
}
const hourly = this.generateWeatherObjectsFromHourly(data.properties.periods);
this.setWeatherHourly(hourly);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
/** Weather.gov Specific Methods - These are not part of the default provider methods */
/*
@ -110,8 +131,8 @@ WeatherProvider.register("weathergov", {
}
this.fetchedLocationName = data.properties.relativeLocation.properties.city + ", " + data.properties.relativeLocation.properties.state;
Log.log("Forecast location is " + this.fetchedLocationName);
this.forecastURL = data.properties.forecast;
this.forecastHourlyURL = data.properties.forecastHourly;
this.forecastURL = data.properties.forecast + "?units=si";
this.forecastHourlyURL = data.properties.forecastHourly + "?units=si";
this.forecastGridDataURL = data.properties.forecastGridData;
this.observationStationsURL = data.properties.observationStations;
// with this URL, we chain another promise for the station obs URL
@ -130,14 +151,49 @@ WeatherProvider.register("weathergov", {
.finally(() => {
// excellent, let's fetch some actual wx data
this.configURLs = true;
// handle 'forecast' config, fall back to 'current'
if (config.type === "forecast") {
this.fetchWeatherForecast();
} else if (config.type === "hourly") {
this.fetchWeatherHourly();
} else {
this.fetchCurrentWeather();
}
});
},
/*
* Generate a WeatherObject based on hourlyWeatherInformation
* Weather.gov API uses specific units; API does not include choice of units
* ... object needs data in units based on config!
*/
generateWeatherObjectsFromHourly(forecasts) {
const days = [];
// variable for date
let weather = new WeatherObject();
for (const forecast of forecasts) {
weather.date = moment(forecast.startTime.slice(0, 19));
if (forecast.windSpeed.search(" ") < 0) {
weather.windSpeed = forecast.windSpeed;
} else {
weather.windSpeed = forecast.windSpeed.slice(0, forecast.windSpeed.search(" "));
}
weather.windDirection = this.convertWindDirection(forecast.windDirection);
weather.temperature = forecast.temperature;
weather.tempUnits = forecast.temperatureUnit;
// use the forecast isDayTime attribute to help build the weatherType label
weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime);
days.push(weather);
weather = new WeatherObject();
}
// push weather information to days array
days.push(weather);
return days;
},
/*
* Generate a WeatherObject based on currentWeatherInformation
@ -145,24 +201,24 @@ WeatherProvider.register("weathergov", {
* ... object needs data in units based on config!
*/
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const currentWeather = new WeatherObject();
currentWeather.date = moment(currentWeatherData.timestamp);
currentWeather.temperature = this.convertTemp(currentWeatherData.temperature.value);
currentWeather.windSpeed = this.convertSpeed(currentWeatherData.windSpeed.value);
currentWeather.temperature = currentWeatherData.temperature.value;
currentWeather.windSpeed = WeatherUtils.convertWindToMs(currentWeatherData.windSpeed.value);
currentWeather.windDirection = currentWeatherData.windDirection.value;
currentWeather.minTemperature = this.convertTemp(currentWeatherData.minTemperatureLast24Hours.value);
currentWeather.maxTemperature = this.convertTemp(currentWeatherData.maxTemperatureLast24Hours.value);
currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value;
currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value;
currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value);
currentWeather.rain = null;
currentWeather.snow = null;
currentWeather.precipitation = this.convertLength(currentWeatherData.precipitationLastHour.value);
if (currentWeatherData.heatIndex.value !== null) {
currentWeather.feelsLikeTemp = this.convertTemp(currentWeatherData.heatIndex.value);
currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value;
} else if (currentWeatherData.windChill.value !== null) {
currentWeather.feelsLikeTemp = this.convertTemp(currentWeatherData.windChill.value);
currentWeather.feelsLikeTemp = currentWeatherData.windChill.value;
} else {
currentWeather.feelsLikeTemp = this.convertTemp(currentWeatherData.temperature.value);
currentWeather.feelsLikeTemp = currentWeatherData.temperature.value;
}
// determine the sunrise/sunset times - not supplied in weather.gov data
currentWeather.updateSunTime(this.config.lat, this.config.lon);
@ -191,7 +247,7 @@ WeatherProvider.register("weathergov", {
let maxTemp = [];
// variable for date
let date = "";
let weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
let weather = new WeatherObject();
weather.precipitation = 0;
for (const forecast of forecasts) {
@ -203,7 +259,7 @@ WeatherProvider.register("weathergov", {
// push weather information to days array
days.push(weather);
// create new weather-object
weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
weather = new WeatherObject();
minTemp = [];
maxTemp = [];
@ -242,26 +298,6 @@ WeatherProvider.register("weathergov", {
/*
* Unit conversions
*/
// conversion to fahrenheit
convertTemp(temp) {
if (this.config.tempUnits === "imperial") {
return (9 / 5) * temp + 32;
} else {
return temp;
}
},
// conversion to mph or kmh
convertSpeed(metSec) {
if (this.config.windUnits === "imperial") {
return metSec * 2.23694;
} else {
if (this.config.useKmh) {
return metSec * 3.6;
} else {
return metSec;
}
}
},
// conversion to inches
convertLength(meters) {
if (this.config.units === "imperial") {
@ -339,31 +375,5 @@ WeatherProvider.register("weathergov", {
}
return null;
},
/*
Convert the direction into Degrees
*/
convertWindDirection(windDirection) {
const windCardinals = {
N: 0,
NNE: 22,
NE: 45,
ENE: 67,
E: 90,
ESE: 112,
SE: 135,
SSE: 157,
S: 180,
SSW: 202,
SW: 225,
WSW: 247,
W: 270,
WNW: 292,
NW: 315,
NNW: 337
};
return windCardinals.hasOwnProperty(windDirection) ? windCardinals[windDirection] : null;
}
});

View file

@ -0,0 +1,626 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
* Provider: Yr.no
*
* By Magnus Marthinsen
* MIT Licensed
*
* This class is a provider for Yr.no, a norwegian sweather service.
*
* Terms of service: https://developer.yr.no/doc/TermsOfService/
*/
WeatherProvider.register("yr", {
providerName: "Yr",
// Set the default config properties that is specific to this provider
defaults: {
useCorsProxy: true,
apiBase: "https://api.met.no/weatherapi",
altitude: 0,
currentForecastHours: 1 //1, 6 or 12
},
start() {
if (typeof Storage === "undefined") {
//local storage unavailable
Log.error("The Yr weather provider requires local storage.");
throw new Error("Local storage not available");
}
Log.info(`Weather provider: ${this.providerName} started.`);
},
fetchCurrentWeather() {
this.getCurrentWeather()
.then((currentWeather) => {
this.setCurrentWeather(currentWeather);
this.updateAvailable();
})
.catch((error) => {
Log.error(error);
throw new Error(error);
});
},
async getCurrentWeather() {
const getRequests = [this.getWeatherData(), this.getStellarData()];
const [weatherData, stellarData] = await Promise.all(getRequests);
if (!stellarData) {
Log.warn("No stelar data available.");
}
if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) {
Log.error("No weather data available.");
return;
}
const currentTime = moment();
let forecast = weatherData.properties.timeseries[0];
let closestTimeInPast = currentTime.diff(moment(forecast.time));
for (const forecastTime of weatherData.properties.timeseries) {
const comparison = currentTime.diff(moment(forecastTime.time));
if (0 < comparison && comparison < closestTimeInPast) {
closestTimeInPast = comparison;
forecast = forecastTime;
}
}
const forecastXHours = this.getForecastForXHoursFrom(forecast.data);
forecast.weatherType = this.convertWeatherType(forecastXHours.summary.symbol_code, forecast.time);
forecast.precipitation = forecastXHours.details?.precipitation_amount;
forecast.minTemperature = forecastXHours.details?.air_temperature_min;
forecast.maxTemperature = forecastXHours.details?.air_temperature_max;
return this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units);
},
getWeatherData() {
return new Promise((resolve, reject) => {
// If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes.
// This is to avoid multiple similar calls to the API.
let shouldWait = localStorage.getItem("yrIsFetchingWeatherData");
if (shouldWait) {
const checkForGo = setInterval(function () {
shouldWait = localStorage.getItem("yrIsFetchingWeatherData");
}, 100);
setTimeout(function () {
clearInterval(checkForGo);
shouldWait = false;
}, 5000); //Assume other fetch finished but failed to remove lock
const attemptFetchWeather = setInterval(() => {
if (!shouldWait) {
clearInterval(checkForGo);
clearInterval(attemptFetchWeather);
this.getWeatherDataFromYrOrCache(resolve, reject);
}
}, 100);
} else {
this.getWeatherDataFromYrOrCache(resolve, reject);
}
});
},
getWeatherDataFromYrOrCache(resolve, reject) {
localStorage.setItem("yrIsFetchingWeatherData", "true");
let weatherData = this.getWeatherDataFromCache();
if (this.weatherDataIsValid(weatherData)) {
localStorage.removeItem("yrIsFetchingWeatherData");
Log.debug("Weather data found in cache.");
resolve(weatherData);
} else {
this.getWeatherDataFromYr(weatherData?.downloadedAt)
.then((weatherData) => {
Log.debug("Got weather data from yr.");
if (weatherData) {
this.cacheWeatherData(weatherData);
} else {
//Undefined if unchanged
weatherData = this.getWeatherDataFromCache();
}
resolve(weatherData);
})
.catch((err) => {
Log.error(err);
reject("Unable to get weather data from Yr.");
})
.finally(() => {
localStorage.removeItem("yrIsFetchingWeatherData");
});
}
},
weatherDataIsValid(weatherData) {
return (
weatherData &&
weatherData.timeout &&
0 < moment(weatherData.timeout).diff(moment()) &&
(!weatherData.geometry || !weatherData.geometry.coordinates || !weatherData.geometry.coordinates.length < 2 || (weatherData.geometry.coordinates[0] === this.config.lat && weatherData.geometry.coordinates[1] === this.config.lon))
);
},
getWeatherDataFromCache() {
const weatherData = localStorage.getItem("weatherData");
if (weatherData) {
return JSON.parse(weatherData);
} else {
return undefined;
}
},
getWeatherDataFromYr(currentDataFetchedAt) {
const requestHeaders = [{ name: "Accept", value: "application/json" }];
if (currentDataFetchedAt) {
requestHeaders.push({ name: "If-Modified-Since", value: currentDataFetchedAt });
}
const expectedResponseHeaders = ["expires", "date"];
return this.fetchData(this.getForecastUrl(), "json", requestHeaders, expectedResponseHeaders)
.then((data) => {
if (!data || !data.headers) return data;
data.timeout = data.headers.find((header) => header.name === "expires").value;
data.downloadedAt = data.headers.find((header) => header.name === "date").value;
data.headers = undefined;
return data;
})
.catch((err) => {
Log.error("Could not load weather data.", err);
throw new Error(err);
});
},
getForecastUrl() {
if (!this.config.lat) {
Log.error("Latitude not provided.");
throw new Error("Latitude not provided.");
}
if (!this.config.lon) {
Log.error("Longitude not provided.");
throw new Error("Longitude not provided.");
}
let lat = this.config.lat.toString();
let lon = this.config.lon.toString();
const altitude = this.config.altitude ?? 0;
if (lat.includes(".") && lat.split(".")[1].length > 4) {
Log.warn("Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.");
const latParts = lat.split(".");
lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`;
}
if (lon.includes(".") && lon.split(".")[1].length > 4) {
Log.warn("Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.");
const lonParts = lon.split(".");
lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`;
}
return `${this.config.apiBase}/locationforecast/2.0/complete?&altitude=${altitude}&lat=${lat}&lon=${lon}`;
},
cacheWeatherData(weatherData) {
localStorage.setItem("weatherData", JSON.stringify(weatherData));
},
getAuthenticationString() {
if (!this.config.authenticationEmail) throw new Error("Authentication email not provided.");
return `${this.config.applicaitionName} ${this.config.authenticationEmail}`;
},
getStellarData() {
// If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes.
// This is to avoid multiple similar calls to the API.
return new Promise((resolve, reject) => {
let shouldWait = localStorage.getItem("yrIsFetchingStellarData");
if (shouldWait) {
const checkForGo = setInterval(function () {
shouldWait = localStorage.getItem("yrIsFetchingStellarData");
}, 100);
setTimeout(function () {
clearInterval(checkForGo);
shouldWait = false;
}, 5000); //Assume other fetch finished but failed to remove lock
const attemptFetchWeather = setInterval(() => {
if (!shouldWait) {
clearInterval(checkForGo);
clearInterval(attemptFetchWeather);
this.getStellarDataFromYrOrCache(resolve, reject);
}
}, 100);
} else {
this.getStellarDataFromYrOrCache(resolve, reject);
}
});
},
getStellarDataFromYrOrCache(resolve, reject) {
localStorage.setItem("yrIsFetchingStellarData", "true");
let stellarData = this.getStellarDataFromCache();
const today = moment().format("YYYY-MM-DD");
const tomorrow = moment().add(1, "days").format("YYYY-MM-DD");
if (stellarData && stellarData.today && stellarData.today.date === today && stellarData.tomorrow && stellarData.tomorrow.date === tomorrow) {
Log.debug("Stellar data found in cache.");
localStorage.removeItem("yrIsFetchingStellarData");
resolve(stellarData);
} else if (stellarData && stellarData.tomorrow && stellarData.tomorrow.date === today) {
Log.debug("stellar data for today found in cache, but not for tomorrow.");
stellarData.today = stellarData.tomorrow;
this.getStellarDataFromYr(tomorrow)
.then((data) => {
if (data) {
data.date = tomorrow;
stellarData.tomorrow = data;
this.cacheStellarData(stellarData);
resolve(stellarData);
} else {
reject("No stellar data returned from Yr for " + tomorrow);
}
})
.catch((err) => {
Log.error(err);
reject("Unable to get stellar data from Yr for " + tomorrow);
})
.finally(() => {
localStorage.removeItem("yrIsFetchingStellarData");
});
} else {
this.getStellarDataFromYr(today, 2)
.then((stellarData) => {
if (stellarData) {
stellarData = {
today: stellarData
};
stellarData.tomorrow = Object.assign({}, stellarData.today);
stellarData.today.date = today;
stellarData.tomorrow.date = tomorrow;
this.cacheStellarData(stellarData);
resolve(stellarData);
} else {
Log.error("Something went wrong when fetching stellar data. Responses: " + stellarData);
reject(stellarData);
}
})
.catch((err) => {
Log.error(err);
reject("Unable to get stellar data from Yr.");
})
.finally(() => {
localStorage.removeItem("yrIsFetchingStellarData");
});
}
},
getStellarDataFromCache() {
const stellarData = localStorage.getItem("stellarData");
if (stellarData) {
return JSON.parse(stellarData);
} else {
return undefined;
}
},
getStellarDataFromYr(date, days = 1) {
const requestHeaders = [{ name: "Accept", value: "application/json" }];
return this.fetchData(this.getStellarDatatUrl(date, days), "json", requestHeaders)
.then((data) => {
Log.debug("Got stellar data from yr.");
return data;
})
.catch((err) => {
Log.error("Could not load weather data.", err);
throw new Error(err);
});
},
getStellarDatatUrl(date, days) {
if (!this.config.lat) {
Log.error("Latitude not provided.");
throw new Error("Latitude not provided.");
}
if (!this.config.lon) {
Log.error("Longitude not provided.");
throw new Error("Longitude not provided.");
}
let lat = this.config.lat.toString();
let lon = this.config.lon.toString();
const altitude = this.config.altitude ?? 0;
if (lat.includes(".") && lat.split(".")[1].length > 4) {
Log.warn("Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.");
const latParts = lat.split(".");
lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`;
}
if (lon.includes(".") && lon.split(".")[1].length > 4) {
Log.warn("Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.");
const lonParts = lon.split(".");
lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`;
}
let utcOffset = moment().utcOffset() / 60;
let utcOffsetPrefix = "%2B";
if (utcOffset < 0) {
utcOffsetPrefix = "-";
}
utcOffset = Math.abs(utcOffset);
let minutes = "00";
if (utcOffset % 1 !== 0) {
minutes = "30";
}
let hours = Math.floor(utcOffset).toString();
if (hours.length < 2) {
hours = `0${hours}`;
}
return `${this.config.apiBase}/sunrise/2.0/.json?date=${date}&days=${days}&height=${altitude}&lat=${lat}&lon=${lon}&offset=${utcOffsetPrefix}${hours}%3A${minutes}`;
},
cacheStellarData(data) {
localStorage.setItem("stellarData", JSON.stringify(data));
},
getWeatherDataFrom(forecast, stellarData, units) {
const weather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits, this.config.useKmh);
const stellarTimesToday = stellarData?.today ? this.getStellarTimesFrom(stellarData.today, moment().format("YYYY-MM-DD")) : undefined;
const stellarTimesTomorrow = stellarData?.tomorrow ? this.getStellarTimesFrom(stellarData.tomorrow, moment().add(1, "days").format("YYYY-MM-DD")) : undefined;
weather.date = moment(forecast.time);
weather.windSpeed = forecast.data.instant.details.wind_speed;
weather.windDirection = (forecast.data.instant.details.wind_from_direction + 180) % 360;
weather.temperature = forecast.data.instant.details.air_temperature;
weather.minTemperature = forecast.minTemperature;
weather.maxTemperature = forecast.maxTemperature;
weather.weatherType = forecast.weatherType;
weather.humidity = forecast.data.instant.details.relative_humidity;
weather.precipitation = forecast.precipitation;
weather.precipitationUnits = units.precipitation_amount;
if (stellarTimesToday) {
weather.sunset = moment(stellarTimesToday.sunset.time);
weather.sunrise = weather.sunset < moment() && stellarTimesTomorrow ? moment(stellarTimesTomorrow.sunrise.time) : moment(stellarTimesToday.sunrise.time);
}
return weather;
},
convertWeatherType(weatherType, weatherTime) {
const weatherHour = moment(weatherTime).format("HH");
const weatherTypes = {
clearsky_day: "day-sunny",
clearsky_night: "night-clear",
clearsky_polartwilight: weatherHour < 14 ? "sunrise" : "sunset",
cloudy: "cloudy",
fair_day: "day-sunny-overcast",
fair_night: "night-alt-partly-cloudy",
fair_polartwilight: "day-sunny-overcast",
fog: "fog",
heavyrain: "rain", // Possibly raindrops or raindrop
heavyrainandthunder: "thunderstorm",
heavyrainshowers_day: "day-rain",
heavyrainshowers_night: "night-alt-rain",
heavyrainshowers_polartwilight: "day-rain",
heavyrainshowersandthunder_day: "day-thunderstorm",
heavyrainshowersandthunder_night: "night-alt-thunderstorm",
heavyrainshowersandthunder_polartwilight: "day-thunderstorm",
heavysleet: "sleet",
heavysleetandthunder: "day-sleet-storm",
heavysleetshowers_day: "day-sleet",
heavysleetshowers_night: "night-alt-sleet",
heavysleetshowers_polartwilight: "day-sleet",
heavysleetshowersandthunder_day: "day-sleet-storm",
heavysleetshowersandthunder_night: "night-alt-sleet-storm",
heavysleetshowersandthunder_polartwilight: "day-sleet-storm",
heavysnow: "snow-wind",
heavysnowandthunder: "day-snow-thunderstorm",
heavysnowshowers_day: "day-snow-wind",
heavysnowshowers_night: "night-alt-snow-wind",
heavysnowshowers_polartwilight: "day-snow-wind",
heavysnowshowersandthunder_day: "day-snow-thunderstorm",
heavysnowshowersandthunder_night: "night-alt-snow-thunderstorm",
heavysnowshowersandthunder_polartwilight: "day-snow-thunderstorm",
lightrain: "rain-mix",
lightrainandthunder: "thunderstorm",
lightrainshowers_day: "day-rain-mix",
lightrainshowers_night: "night-alt-rain-mix",
lightrainshowers_polartwilight: "day-rain-mix",
lightrainshowersandthunder_day: "thunderstorm",
lightrainshowersandthunder_night: "thunderstorm",
lightrainshowersandthunder_polartwilight: "thunderstorm",
lightsleet: "day-sleet",
lightsleetandthunder: "day-sleet-storm",
lightsleetshowers_day: "day-sleet",
lightsleetshowers_night: "night-alt-sleet",
lightsleetshowers_polartwilight: "day-sleet",
lightsnow: "snowflake-cold",
lightsnowandthunder: "day-snow-thunderstorm",
lightsnowshowers_day: "day-snow-wind",
lightsnowshowers_night: "night-alt-snow-wind",
lightsnowshowers_polartwilight: "day-snow-wind",
lightssleetshowersandthunder_day: "day-sleet-storm",
lightssleetshowersandthunder_night: "night-alt-sleet-storm",
lightssleetshowersandthunder_polartwilight: "day-sleet-storm",
lightssnowshowersandthunder_day: "day-snow-thunderstorm",
lightssnowshowersandthunder_night: "night-alt-snow-thunderstorm",
lightssnowshowersandthunder_polartwilight: "day-snow-thunderstorm",
partlycloudy_day: "day-cloudy",
partlycloudy_night: "night-alt-cloudy",
partlycloudy_polartwilight: "day-cloudy",
rain: "rain",
rainandthunder: "thunderstorm",
rainshowers_day: "day-rain",
rainshowers_night: "night-alt-rain",
rainshowers_polartwilight: "day-rain",
rainshowersandthunder_day: "thunderstorm",
rainshowersandthunder_night: "lightning",
rainshowersandthunder_polartwilight: "thunderstorm",
sleet: "sleet",
sleetandthunder: "day-sleet-storm",
sleetshowers_day: "day-sleet",
sleetshowers_night: "night-alt-sleet",
sleetshowers_polartwilight: "day-sleet",
sleetshowersandthunder_day: "day-sleet-storm",
sleetshowersandthunder_night: "night-alt-sleet-storm",
sleetshowersandthunder_polartwilight: "day-sleet-storm",
snow: "snowflake-cold",
snowandthunder: "lightning",
snowshowers_day: "day-snow-wind",
snowshowers_night: "night-alt-snow-wind",
snowshowers_polartwilight: "day-snow-wind",
snowshowersandthunder_day: "day-snow-thunderstorm",
snowshowersandthunder_night: "night-alt-snow-thunderstorm",
snowshowersandthunder_polartwilight: "day-snow-thunderstorm"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
},
getStellarTimesFrom(stellarData, date) {
for (const time of stellarData.location.time) {
if (time.date === date) {
return time;
}
}
return undefined;
},
getForecastForXHoursFrom(weather) {
if (this.config.currentForecastHours === 1) {
if (weather.next_1_hours) {
return weather.next_1_hours;
} else if (weather.next_6_hours) {
return weather.next_6_hours;
} else {
return weather.next_12_hours;
}
} else if (this.config.currentForecastHours === 6) {
if (weather.next_6_hours) {
return weather.next_6_hours;
} else if (weather.next_12_hours) {
return weather.next_12_hours;
} else {
return weather.next_1_hours;
}
} else {
if (weather.next_12_hours) {
return weather.next_12_hours;
} else if (weather.next_6_hours) {
return weather.next_6_hours;
} else {
return weather.next_1_hours;
}
}
},
fetchWeatherHourly() {
this.getWeatherForecast("hourly")
.then((forecast) => {
this.setWeatherHourly(forecast);
this.updateAvailable();
})
.catch((error) => {
Log.error(error);
throw new Error(error);
});
},
async getWeatherForecast(type) {
const getRequests = [this.getWeatherData(), this.getStellarData()];
const [weatherData, stellarData] = await Promise.all(getRequests);
if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) {
Log.error("No weather data available.");
return;
}
if (!stellarData) {
Log.warn("No stelar data available.");
}
let forecasts;
switch (type) {
case "hourly":
forecasts = this.getHourlyForecastFrom(weatherData);
break;
case "daily":
default:
forecasts = this.getDailyForecastFrom(weatherData);
break;
}
const series = [];
for (const forecast of forecasts) {
series.push(this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units));
}
return series;
},
getHourlyForecastFrom(weatherData) {
const series = [];
for (const forecast of weatherData.properties.timeseries) {
forecast.symbol = forecast.data.next_1_hours?.summary?.symbol_code;
forecast.precipitation = forecast.data.next_1_hours?.details?.precipitation_amount;
forecast.minTemperature = forecast.data.next_1_hours?.details?.air_temperature_min;
forecast.maxTemperature = forecast.data.next_1_hours?.details?.air_temperature_max;
forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time);
series.push(forecast);
}
return series;
},
getDailyForecastFrom(weatherData) {
const series = [];
const days = weatherData.properties.timeseries.reduce(function (days, forecast) {
const date = moment(forecast.time).format("YYYY-MM-DD");
days[date] = days[date] || [];
days[date].push(forecast);
return days;
}, Object.create(null));
Object.keys(days).forEach(function (time, index) {
let minTemperature = undefined;
let maxTemperature = undefined;
//Default to first entry
let forecast = days[time][0];
forecast.symbol = forecast.data.next_12_hours?.summary?.symbol_code;
forecast.precipitation = forecast.data.next_12_hours?.details?.precipitation_amount;
//Coming days
let forecastDiffToEight = undefined;
for (const timeseries of days[time]) {
if (!timeseries.data.next_6_hours) continue; //next_6_hours has the most data
if (!minTemperature || timeseries.data.next_6_hours.details.air_temperature_min < minTemperature) minTemperature = timeseries.data.next_6_hours.details.air_temperature_min;
if (!maxTemperature || maxTemperature < timeseries.data.next_6_hours.details.air_temperature_max) maxTemperature = timeseries.data.next_6_hours.details.air_temperature_max;
let closestTime = Math.abs(moment(timeseries.time).local().set({ hour: 8, minute: 0, second: 0, millisecond: 0 }).diff(moment(timeseries.time).local()));
if ((forecastDiffToEight === undefined || closestTime < forecastDiffToEight) && timeseries.data.next_12_hours) {
forecastDiffToEight = closestTime;
forecast = timeseries;
}
}
const forecastXHours = forecast.data.next_12_hours ?? forecast.data.next_6_hours ?? forecast.data.next_1_hours;
if (forecastXHours) {
forecast.symbol = forecastXHours.summary?.symbol_code;
forecast.precipitation = forecastXHours.details?.precipitation_amount;
forecast.minTemperature = minTemperature;
forecast.maxTemperature = maxTemperature;
series.push(forecast);
}
});
for (const forecast of series) {
forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time);
}
return series;
},
fetchWeatherForecast() {
this.getWeatherForecast("daily")
.then((forecast) => {
this.setWeatherForecast(forecast);
this.updateAvailable();
})
.catch((error) => {
Log.error(error);
throw new Error(error);
});
}
});

View file

@ -1,4 +1,4 @@
/* global WeatherProvider */
/* global WeatherProvider, WeatherUtils */
/* MagicMirror²
* Module: Weather
@ -13,7 +13,6 @@ Module.register("weather", {
roundTemp: false,
type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint)
units: config.units,
useKmh: false,
tempUnits: config.units,
windUnits: config.units,
updateInterval: 10 * 60 * 1000, // every 10 minutes
@ -23,7 +22,6 @@ Module.register("weather", {
showPeriodUpper: false,
showWindDirection: true,
showWindDirectionAsArrow: false,
useBeaufort: true,
lang: config.language,
showHumidity: false,
showSun: true,
@ -60,7 +58,7 @@ Module.register("weather", {
// Return the scripts that are necessary for the weather module.
getScripts: function () {
return ["moment.js", "weatherprovider.js", "weatherobject.js", "suncalc.js", this.file("providers/" + this.config.weatherProvider.toLowerCase() + ".js")];
return ["moment.js", this.file("../utils.js"), "weatherutils.js", "weatherprovider.js", "weatherobject.js", "suncalc.js", this.file("providers/" + this.config.weatherProvider.toLowerCase() + ".js")];
},
// Override getHeader method.
@ -77,6 +75,14 @@ Module.register("weather", {
start: function () {
moment.locale(this.config.lang);
if (this.config.useKmh) {
Log.warn("Your are using the deprecated config values 'useKmh'. Please switch to windUnits!");
this.windUnits = "kmh";
} else if (this.config.useBeaufort) {
Log.warn("Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!");
this.windUnits = "beaufort";
}
// Initialize the weather provider.
this.weatherProvider = WeatherProvider.initialize(this.config.weatherProvider, this);
@ -221,9 +227,7 @@ Module.register("weather", {
"unit",
function (value, type) {
if (type === "temperature") {
if (this.config.tempUnits === "metric" || this.config.tempUnits === "imperial") {
value += "°";
}
value = this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits)) + "°";
if (this.config.degreeLabel) {
if (this.config.tempUnits === "metric") {
value += "C";
@ -245,8 +249,9 @@ Module.register("weather", {
}
} else if (type === "humidity") {
value += "%";
} else if (type === "wind") {
value = WeatherUtils.convertWind(value, this.config.windUnits);
}
return value;
}.bind(this)
);

View file

@ -1,4 +1,4 @@
/* global SunCalc */
/* global SunCalc, WeatherUtils */
/* MagicMirror²
* Module: Weather
@ -14,17 +14,8 @@
class WeatherObject {
/**
* Constructor for a WeatherObject
*
* @param {string} units what units to use, "imperial" or "metric"
* @param {string} tempUnits what tempunits to use
* @param {string} windUnits what windunits to use
* @param {boolean} useKmh use kmh if true, mps if false
*/
constructor(units, tempUnits, windUnits, useKmh) {
this.units = units;
this.tempUnits = tempUnits;
this.windUnits = windUnits;
this.useKmh = useKmh;
constructor() {
this.date = null;
this.windSpeed = null;
this.windDirection = null;
@ -78,21 +69,6 @@ class WeatherObject {
}
}
beaufortWindSpeed() {
const windInKmh = this.windUnits === "imperial" ? this.windSpeed * 1.609344 : this.useKmh ? this.windSpeed : (this.windSpeed * 60 * 60) / 1000;
const speeds = [1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117, 1000];
for (const [index, speed] of speeds.entries()) {
if (speed > windInKmh) {
return index;
}
}
return 12;
}
kmhWindSpeed() {
return this.windUnits === "imperial" ? this.windSpeed * 1.609344 : (this.windSpeed * 60 * 60) / 1000;
}
nextSunAction() {
return moment().isBetween(this.sunrise, this.sunset) ? "sunset" : "sunrise";
}
@ -101,8 +77,8 @@ class WeatherObject {
if (this.feelsLikeTemp) {
return this.feelsLikeTemp;
}
const windInMph = this.windUnits === "imperial" ? this.windSpeed : this.windSpeed * 2.23694;
const tempInF = this.tempUnits === "imperial" ? this.temperature : (this.temperature * 9) / 5 + 32;
const windInMph = WeatherUtils.convertWind(this.windSpeed, "imperial");
const tempInF = WeatherUtils.convertTemp(this.temperature, "imperial");
let feelsLike = tempInF;
if (windInMph > 3 && tempInF < 50) {
@ -120,7 +96,7 @@ class WeatherObject {
1.99 * Math.pow(10, -6) * tempInF * tempInF * this.humidity * this.humidity;
}
return this.tempUnits === "imperial" ? feelsLike : ((feelsLike - 32) * 5) / 9;
return ((feelsLike - 32) * 5) / 9;
}
/**
@ -134,17 +110,17 @@ class WeatherObject {
/**
* Update the sunrise / sunset time depending on the location. This can be
* used if your provider doesnt provide that data by itself. Then SunCalc
* used if your provider doesn't provide that data by itself. Then SunCalc
* is used here to calculate them according to the location.
*
* @param {number} lat latitude
* @param {number} lon longitude
*/
updateSunTime(lat, lon) {
let now = !this.date ? new Date() : this.date.toDate();
let times = SunCalc.getTimes(now, lat, lon);
this.sunrise = moment(times.sunrise, "X");
this.sunset = moment(times.sunset, "X");
const now = !this.date ? new Date() : this.date.toDate();
const times = SunCalc.getTimes(now, lat, lon);
this.sunrise = moment(times.sunrise);
this.sunset = moment(times.sunset);
}
/**

View file

@ -1,4 +1,4 @@
/* global Class */
/* global Class, performWebRequest */
/* MagicMirror²
* Module: Weather
@ -111,45 +111,23 @@ const WeatherProvider = Class.extend({
this.delegate.updateAvailable(this);
},
getCorsUrl: function () {
if (this.config.mockData || typeof this.config.useCorsProxy === "undefined" || !this.config.useCorsProxy) {
return "";
} else {
return location.protocol + "//" + location.host + "/cors?url=";
/**
* A convenience function to make requests.
*
* @param {string} url the url to fetch from
* @param {string} type what contenttype to expect in the response, can be "json" or "xml"
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @returns {Promise} resolved when the fetch is done
*/
fetchData: async function (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) {
const mockData = this.config.mockData;
if (mockData) {
const data = mockData.substring(1, mockData.length - 1);
return JSON.parse(data);
}
},
// A convenience function to make requests. It returns a promise.
fetchData: function (url, method = "GET", type = "json") {
url = this.getCorsUrl() + url;
const getData = function (mockData) {
return new Promise(function (resolve, reject) {
if (mockData) {
let data = mockData;
data = data.substring(1, data.length - 1);
resolve(JSON.parse(data));
} else {
const request = new XMLHttpRequest();
request.open(method, url, true);
request.onreadystatechange = function () {
if (this.readyState === 4) {
if (this.status === 200) {
if (type === "xml") {
resolve(this.responseXML);
} else {
resolve(JSON.parse(this.response));
}
} else {
reject(request);
}
}
};
request.send();
}
});
};
return getData(this.config.mockData);
const useCorsProxy = typeof this.config.useCorsProxy !== "undefined" && this.config.useCorsProxy;
return performWebRequest(url, type, useCorsProxy, requestHeaders, expectedResponseHeaders);
}
});

View file

@ -0,0 +1,98 @@
/* MagicMirror²
* Weather Util Methods
*
* By Rejas
* MIT Licensed.
*/
const WeatherUtils = {
/**
* Convert wind (from m/s) to beaufort scale
*
* @param {number} speedInMS the windspeed you want to convert
* @returns {number} the speed in beaufort
*/
beaufortWindSpeed(speedInMS) {
const windInKmh = (speedInMS * 3600) / 1000;
const speeds = [1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117, 1000];
for (const [index, speed] of speeds.entries()) {
if (speed > windInKmh) {
return index;
}
}
return 12;
},
/**
* 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 {string} unit can be 'imperial' or 'metric'
* @returns {number} the converted temperature
*/
convertTemp(tempInC, unit) {
return unit === "imperial" ? tempInC * 1.8 + 32 : tempInC;
},
/**
* Convert wind speed into another unit.
*
* @param {number} windInMS the windspeed in meter/sec you want to convert
* @param {string} unit can be 'beaufort', 'kmh', 'knots, 'imperial' (mph)
* or 'metric' (mps)
* @returns {number} the converted windspeed
*/
convertWind(windInMS, unit) {
switch (unit) {
case "beaufort":
return this.beaufortWindSpeed(windInMS);
case "kmh":
return (windInMS * 3600) / 1000;
case "knots":
return windInMS * 1.943844;
case "imperial":
return windInMS * 2.2369362920544;
case "metric":
default:
return windInMS;
}
},
/*
* Convert the wind direction cardinal to value
*/
convertWindDirection(windDirection) {
const windCardinals = {
N: 0,
NNE: 22,
NE: 45,
ENE: 67,
E: 90,
ESE: 112,
SE: 135,
SSE: 157,
S: 180,
SSW: 202,
SW: 225,
WSW: 247,
W: 270,
WNW: 292,
NW: 315,
NNW: 337
};
return windCardinals.hasOwnProperty(windDirection) ? windCardinals[windDirection] : null;
},
convertWindToMetric(mph) {
return mph / 2.2369362920544;
},
convertWindToMs(kmh) {
return kmh * 0.27777777777778;
}
};
if (typeof module !== "undefined") {
module.exports = WeatherUtils;
}

5361
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "magicmirror",
"version": "2.21.0",
"version": "2.22.0",
"description": "The open source modular smart mirror platform.",
"main": "js/electron.js",
"scripts": {
@ -13,7 +13,7 @@
"install-fonts": "echo \"Installing fonts ...\n\" && cd fonts && npm install --loglevel=error --no-audit --no-fund --no-update-notifier",
"postinstall": "npm run install-vendor && npm run install-fonts && echo \"MagicMirror² installation finished successfully! \n\"",
"test": "NODE_ENV=test jest -i --forceExit",
"test:coverage": "NODE_ENV=test nyc --reporter=lcov --reporter=text jest -i --forceExit",
"test:coverage": "NODE_ENV=test jest --coverage -i --forceExit",
"test:electron": "NODE_ENV=test jest --selectProjects electron -i --forceExit",
"test:e2e": "NODE_ENV=test jest --selectProjects e2e -i --forceExit",
"test:unit": "NODE_ENV=test jest --selectProjects unit -i --forceExit",
@ -50,44 +50,43 @@
"homepage": "https://magicmirror.builders",
"devDependencies": {
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^27.0.4",
"eslint-plugin-jsdoc": "^39.3.6",
"eslint-plugin-jest": "^27.1.7",
"eslint-plugin-jsdoc": "^39.6.4",
"eslint-plugin-prettier": "^4.2.1",
"express-basic-auth": "^1.2.1",
"husky": "^8.0.1",
"jest": "^29.0.3",
"jsdom": "^20.0.0",
"husky": "^8.0.2",
"jest": "^29.3.1",
"jsdom": "^20.0.3",
"lodash": "^4.17.21",
"nyc": "^15.1.0",
"playwright": "^1.26.1",
"prettier": "^2.7.1",
"playwright": "^1.29.1",
"prettier": "^2.8.1",
"pretty-quick": "^3.1.3",
"sinon": "^14.0.0",
"stylelint": "^14.12.1",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^28.0.0",
"sinon": "^15.0.1",
"stylelint": "^14.16.0",
"stylelint-config-prettier": "^9.0.4",
"stylelint-config-standard": "^29.0.0",
"stylelint-prettier": "^2.0.0",
"suncalc": "^1.9.0"
},
"optionalDependencies": {
"electron": "^19.1.0"
"electron": "^22.0.0"
},
"dependencies": {
"colors": "^1.4.0",
"console-stamp": "^3.0.6",
"digest-fetch": "^1.3.0",
"eslint": "^8.24.0",
"express": "^4.18.1",
"console-stamp": "^3.1.0",
"digest-fetch": "^2.0.1",
"eslint": "^8.30.0",
"express": "^4.18.2",
"express-ipfilter": "^1.3.1",
"feedme": "^2.0.2",
"helmet": "^6.0.0",
"helmet": "^6.0.1",
"iconv-lite": "^0.6.3",
"luxon": "^1.28.0",
"module-alias": "^2.2.2",
"moment": "^2.29.4",
"node-fetch": "^2.6.7",
"node-ical": "^0.15.1",
"socket.io": "^4.5.2"
"node-ical": "^0.15.3",
"socket.io": "^4.5.4"
},
"_moduleAliases": {
"node_helper": "js/node_helper.js",
@ -96,48 +95,5 @@
},
"engines": {
"node": ">=14"
},
"jest": {
"verbose": true,
"testTimeout": 20000,
"testSequencer": "<rootDir>/tests/configs/test_sequencer.js",
"projects": [
{
"displayName": "unit",
"moduleNameMapper": {
"logger": "<rootDir>/js/logger.js"
},
"testMatch": [
"**/tests/unit/**/*.[jt]s?(x)"
],
"testPathIgnorePatterns": [
"<rootDir>/tests/unit/mocks"
]
},
{
"displayName": "electron",
"testMatch": [
"**/tests/electron/**/*.[jt]s?(x)"
]
},
{
"displayName": "e2e",
"setupFilesAfterEnv": [
"<rootDir>/tests/e2e/mock-console.js"
],
"testMatch": [
"**/tests/e2e/**/*.[jt]s?(x)"
],
"modulePaths": [
"<rootDir>/js/"
],
"testPathIgnorePatterns": [
"<rootDir>/tests/e2e/modules/mocks",
"<rootDir>/tests/e2e/modules/basic-auth.js",
"<rootDir>/tests/e2e/global-setup.js",
"<rootDir>/tests/e2e/mock-console.js"
]
}
]
}
}

View file

@ -1,33 +0,0 @@
{
"LOADING": "Loading &hellip;",
"TODAY": "Today",
"TOMORROW": "Tomorrow",
"DAYAFTERTOMORROW": "In 2 days",
"RUNNING": "Ends in",
"EMPTY": "No upcoming events.",
"WEEK": "Week {weekNumber}",
"N": "N",
"NNE": "NNE",
"NE": "NE",
"ENE": "ENE",
"E": "E",
"ESE": "ESE",
"SE": "SE",
"SSE": "SSE",
"S": "S",
"SSW": "SSW",
"SW": "SW",
"WSW": "WSW",
"W": "W",
"WNW": "WNW",
"NW": "NW",
"NNW": "NNW",
"UPDATE_NOTIFICATION": "MagicMirror² update available.",
"UPDATE_NOTIFICATION_MODULE": "Update available for MODULE_NAME module.",
"UPDATE_INFO_SINGLE": "The current installation is COMMIT_COUNT commit behind on the BRANCH_NAME branch.",
"UPDATE_INFO_MULTIPLE": "The current installation is COMMIT_COUNT commits behind on the BRANCH_NAME branch."
}

View file

@ -3,7 +3,7 @@
* By Rodrigo Ramírez Norambuena https://rodrigoramirez.com
* MIT Licensed.
*/
exports.configFactory = function (options) {
exports.configFactory = (options) => {
return Object.assign(
{
electronOptions: {

View file

@ -14,7 +14,7 @@ let config = {
calendars: [
{
maximumNumberOfDays: 10000,
url: "http://localhost:8080/tests/configs/data/calendar_test.ics",
url: "http://localhost:8080/tests/mocks/calendar_test.ics",
auth: {
user: "MagicMirror",
pass: "CallMeADog"

View file

@ -14,7 +14,7 @@ let config = {
calendars: [
{
maximumNumberOfDays: 10000,
url: "http://localhost:8080/tests/configs/data/calendar_test.ics",
url: "http://localhost:8080/tests/mocks/calendar_test.ics",
auth: {
user: "MagicMirror",
pass: "CallMeADog",

View file

@ -14,7 +14,7 @@ let config = {
calendars: [
{
maximumNumberOfDays: 10000,
url: "http://localhost:8010/tests/configs/data/calendar_test.ics",
url: "http://localhost:8010/tests/mocks/calendar_test.ics",
auth: {
user: "MagicMirror",
pass: "CallMeADog"

View file

@ -18,7 +18,7 @@ let config = {
symbol: "birthday-cake",
fullDaySymbol: "calendar-day",
recurringSymbol: "undo",
url: "http://localhost:8080/tests/configs/data/calendar_test_icons.ics"
url: "http://localhost:8080/tests/mocks/calendar_test_icons.ics"
}
]
}

View file

@ -14,7 +14,7 @@ let config = {
calendars: [
{
maximumNumberOfDays: 10000,
url: "http://localhost:8080/tests/configs/data/calendar_test.ics"
url: "http://localhost:8080/tests/mocks/calendar_test.ics"
}
]
}

View file

@ -16,7 +16,7 @@ let config = {
calendars: [
{
maximumNumberOfDays: 10000,
url: "http://localhost:8020/tests/configs/data/calendar_test.ics",
url: "http://localhost:8020/tests/mocks/calendar_test.ics",
auth: {
user: "MagicMirror",
pass: "StairwayToHeaven",

View file

@ -14,7 +14,7 @@ let config = {
calendars: [
{
maximumNumberOfDays: 10000,
url: "http://localhost:8080/tests/configs/data/calendar_test.ics",
url: "http://localhost:8080/tests/mocks/calendar_test.ics",
user: "MagicMirror",
pass: "CallMeADog"
}

View file

@ -15,7 +15,7 @@ let config = {
{
maximumEntries: 6,
maximumNumberOfDays: 3650,
url: "http://localhost:8080/tests/configs/data/calendar_test_recurring.ics"
url: "http://localhost:8080/tests/mocks/calendar_test_recurring.ics"
}
]
}

View file

@ -11,7 +11,6 @@ let config = {
module: "compliments",
position: "middle_center",
config: {
mockDate: "2020-01-01",
compliments: {
morning: [],
afternoon: [],

View file

@ -0,0 +1,21 @@
/* MagicMirror² Test config compliments with remote file
*
* By Rejas
* MIT Licensed.
*/
let config = {
modules: [
{
module: "compliments",
position: "middle_center",
config: {
remoteFile: "http://localhost:8080/tests/mocks/compliments_test.json"
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View file

@ -14,7 +14,7 @@ let config = {
feeds: [
{
title: "Rodrigo Ramirez Blog",
url: "http://localhost:8080/tests/configs/data/feed_test_rodrigoramirez.xml"
url: "http://localhost:8080/tests/mocks/newsfeed_test.xml"
}
]
}

View file

@ -13,7 +13,7 @@ let config = {
feeds: [
{
title: "Rodrigo Ramirez Blog",
url: "http://localhost:8080/tests/configs/data/feed_test_rodrigoramirez.xml"
url: "http://localhost:8080/tests/mocks/newsfeed_test.xml"
}
],
ignoreOldItems: true

View file

@ -13,7 +13,7 @@ let config = {
feeds: [
{
title: "Rodrigo Ramirez Blog",
url: "http://localhost:8080/tests/configs/data/feed_test_rodrigoramirez.xml"
url: "http://localhost:8080/tests/mocks/newsfeed_test.xml"
}
],
prohibitedWords: ["QPanel"],

View file

@ -6,7 +6,7 @@
let config = {
modules:
// Using exotic content. This is why don't accept go to JSON configuration file
(function () {
(() => {
let positions = ["top_bar", "top_left", "top_center", "top_right", "upper_third", "middle_center", "lower_third", "bottom_left", "bottom_center", "bottom_right", "bottom_bar", "fullscreen_above", "fullscreen_below"];
let modules = Array();
for (let idx in positions) {

View file

@ -11,7 +11,7 @@ let config = {
config: {
location: "Munich",
mockData: '"#####WEATHERDATA#####"',
useBeaufort: false,
windUnits: "beaufort",
showWindDirectionAsArrow: true,
showSun: false,
showHumidity: true,

View file

@ -1,34 +1,27 @@
const fetch = require("fetch");
const helpers = require("./global-setup");
const helpers = require("./helpers/global-setup");
describe("App environment", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/default.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/default.js");
await helpers.getDocument();
});
afterAll(async () => {
await helpers.stopApplication();
});
it("get request from http://localhost:8080 should return 200", (done) => {
fetch("http://localhost:8080").then((res) => {
done();
expect(res.status).toBe(200);
});
it("get request from http://localhost:8080 should return 200", async () => {
const res = await helpers.fetch("http://localhost:8080");
expect(res.status).toBe(200);
});
it("get request from http://localhost:8080/nothing should return 404", (done) => {
fetch("http://localhost:8080/nothing").then((res) => {
done();
expect(res.status).toBe(404);
});
it("get request from http://localhost:8080/nothing should return 404", async () => {
const res = await helpers.fetch("http://localhost:8080/nothing");
expect(res.status).toBe(404);
});
it("should show the title MagicMirror²", (done) => {
helpers.waitForElement("title").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toBe("MagicMirror²");
});
it("should show the title MagicMirror²", async () => {
const elem = await helpers.waitForElement("title");
expect(elem).not.toBe(null);
expect(elem.textContent).toBe("MagicMirror²");
});
});

View file

@ -1,7 +1,6 @@
const fetch = require("fetch");
const helpers = require("./global-setup");
const helpers = require("./helpers/global-setup");
describe("All font files from roboto.css should be downloadable", function () {
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("fs").readFileSync(__dirname + "/../../fonts/roboto.css", "utf8");
@ -14,18 +13,16 @@ describe("All font files from roboto.css should be downloadable", function () {
match = regex.exec(fileContent);
}
beforeAll(function () {
helpers.startApplication("tests/configs/without_modules.js");
beforeAll(async () => {
await helpers.startApplication("tests/configs/without_modules.js");
});
afterAll(async function () {
afterAll(async () => {
await helpers.stopApplication();
});
test.each(fontFiles)("should return 200 HTTP code for file '%s'", (fontFile, done) => {
test.each(fontFiles)("should return 200 HTTP code for file '%s'", async (fontFile) => {
const fontUrl = "http://localhost:8080/fonts/" + fontFile;
fetch(fontUrl).then((res) => {
expect(res.status).toBe(200);
done();
});
const res = await helpers.fetch(fontUrl);
expect(res.status).toBe(200);
});
});

View file

@ -11,7 +11,7 @@ const basicAuth = auth({
app.use(basicAuth);
// Set available directories
const directories = ["/tests/configs"];
const directories = ["/tests/configs", "/tests/mocks"];
const rootPath = path.resolve(__dirname + "/../../../");
for (let directory of directories) {
@ -20,10 +20,10 @@ for (let directory of directories) {
let server;
exports.listen = function () {
server = app.listen.apply(app, arguments);
exports.listen = (...args) => {
server = app.listen.apply(app, args);
};
exports.close = function (callback) {
server.close(callback);
exports.close = async () => {
await server.close();
};

View file

@ -1,8 +1,11 @@
const jsdom = require("jsdom");
const corefetch = require("fetch");
exports.startApplication = (configFilename, exec) => {
exports.startApplication = async (configFilename, exec) => {
jest.resetModules();
this.stopApplication();
if (global.app) {
await this.stopApplication();
}
// Set config sample for use in test
if (configFilename === "") {
process.env.MM_CONFIG_FILE = "config/config.js";
@ -11,24 +14,33 @@ exports.startApplication = (configFilename, exec) => {
}
if (exec) exec;
global.app = require("app.js");
global.app.start();
return new Promise((resolve) => {
global.app.start(resolve);
});
};
exports.stopApplication = async () => {
if (global.app) {
global.app.stop();
return new Promise((resolve) => {
global.app.stop(resolve);
delete global.app;
});
}
await new Promise((resolve) => setTimeout(resolve, 100));
return Promise.resolve();
};
exports.getDocument = (callback) => {
const url = "http://" + (config.address || "localhost") + ":" + (config.port || "8080");
jsdom.JSDOM.fromURL(url, { resources: "usable", runScripts: "dangerously" }).then((dom) => {
dom.window.name = "jsdom";
dom.window.onload = () => {
global.document = dom.window.document;
callback();
};
exports.getDocument = () => {
return new Promise((resolve) => {
const url = "http://" + (config.address || "localhost") + ":" + (config.port || "8080");
jsdom.JSDOM.fromURL(url, { resources: "usable", runScripts: "dangerously" }).then((dom) => {
dom.window.name = "jsdom";
dom.window.fetch = corefetch;
dom.window.onload = () => {
global.document = dom.window.document;
resolve();
};
});
});
};
@ -71,3 +83,17 @@ exports.waitForAllElements = (selector) => {
}, 100);
});
};
exports.fetch = (url) => {
return new Promise((resolve) => {
corefetch(url).then((res) => {
resolve(res);
});
});
};
exports.testMatch = async (element, regex) => {
const elem = await this.waitForElement(element);
expect(elem).not.toBe(null);
expect(elem.textContent).toMatch(regex);
};

View file

@ -3,13 +3,13 @@
*
* @param {string} err The error message.
*/
function mockError(err) {
const mockError = (err) => {
if (err.includes("ECONNREFUSED") || err.includes("ECONNRESET") || err.includes("socket hang up") || err.includes("exports is not defined") || err.includes("write EPIPE")) {
jest.fn();
} else {
console.dir(err);
}
}
};
global.console = {
log: jest.fn(),

View file

@ -0,0 +1,29 @@
const helpers = require("./global-setup");
const path = require("path");
const fs = require("fs");
const { generateWeather, generateWeatherForecast } = require("../../mocks/weather_test");
exports.getText = async (element, result) => {
const elem = await helpers.waitForElement(element);
expect(elem).not.toBe(null);
expect(
elem.textContent
.trim()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/[ ]+/g, " ")
).toBe(result);
};
exports.startApp = async (configFile, additionalMockData) => {
let mockWeather;
if (configFile.includes("forecast")) {
mockWeather = generateWeatherForecast(additionalMockData);
} else {
mockWeather = generateWeather(additionalMockData);
}
let content = fs.readFileSync(path.resolve(__dirname + "../../../../" + configFile)).toString();
content = content.replace("#####WEATHERDATA#####", mockWeather);
fs.writeFileSync(path.resolve(__dirname + "../../../../config/config.js"), content);
await helpers.startApplication("");
await helpers.getDocument();
};

View file

@ -1,36 +1,31 @@
const fetch = require("fetch");
const helpers = require("./global-setup");
const helpers = require("./helpers/global-setup");
describe("ipWhitelist directive configuration", function () {
describe("Set ipWhitelist without access", function () {
beforeAll(function () {
helpers.startApplication("tests/configs/noIpWhiteList.js");
describe("ipWhitelist directive configuration", () => {
describe("Set ipWhitelist without access", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/noIpWhiteList.js");
});
afterAll(async function () {
afterAll(async () => {
await helpers.stopApplication();
});
it("should return 403", function (done) {
fetch("http://localhost:8080").then((res) => {
expect(res.status).toBe(403);
done();
});
it("should return 403", async () => {
const res = await helpers.fetch("http://localhost:8080");
expect(res.status).toBe(403);
});
});
describe("Set ipWhitelist []", function () {
beforeAll(function () {
helpers.startApplication("tests/configs/empty_ipWhiteList.js");
describe("Set ipWhitelist []", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/empty_ipWhiteList.js");
});
afterAll(async function () {
afterAll(async () => {
await helpers.stopApplication();
});
it("should return 200", function (done) {
fetch("http://localhost:8080").then((res) => {
expect(res.status).toBe(200);
done();
});
it("should return 200", async () => {
const res = await helpers.fetch("http://localhost:8080");
expect(res.status).toBe(200);
});
});
});

View file

@ -1,19 +1,17 @@
const helpers = require("../global-setup");
const helpers = require("../helpers/global-setup");
describe("Alert module", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/alert/default.js");
helpers.getDocument(done);
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", (done) => {
helpers.waitForElement(".ns-box .ns-box-inner .light.bright.small").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Welcome, start was successful!");
});
it("should show the welcome message", async () => {
const elem = await helpers.waitForElement(".ns-box .ns-box-inner .light.bright.small");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Welcome, start was successful!");
});
});

View file

@ -1,31 +1,26 @@
const helpers = require("../global-setup");
const serverBasicAuth = require("./basic-auth.js");
const helpers = require("../helpers/global-setup");
const serverBasicAuth = require("../helpers/basic-auth.js");
describe("Calendar module", () => {
/**
* @param {string} done test done
* @param {string} element css selector
* @param {string} result expected number
* @param {string} not reverse result
*/
const testElementLength = (done, element, result, not) => {
helpers.waitForAllElements(element).then((elem) => {
done();
expect(elem).not.toBe(null);
if (not === "not") {
expect(elem.length).not.toBe(result);
} else {
expect(elem.length).toBe(result);
}
});
const testElementLength = async (element, result, not) => {
const elem = await helpers.waitForAllElements(element);
expect(elem).not.toBe(null);
if (not === "not") {
expect(elem.length).not.toBe(result);
} else {
expect(elem.length).toBe(result);
}
};
const testTextContain = (done, element, text) => {
helpers.waitForElement(element, "undefinedLoading").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain(text);
});
const testTextContain = async (element, text) => {
const elem = await helpers.waitForElement(element, "undefinedLoading");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain(text);
};
afterAll(async () => {
@ -33,133 +28,133 @@ describe("Calendar module", () => {
});
describe("Default configuration", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/default.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/default.js");
await helpers.getDocument();
});
it("should show the default maximumEntries of 10", (done) => {
testElementLength(done, ".calendar .event", 10);
it("should show the default maximumEntries of 10", async () => {
await testElementLength(".calendar .event", 10);
});
it("should show the default calendar symbol in each event", (done) => {
testElementLength(done, ".calendar .event .fa-calendar-alt", 0, "not");
it("should show the default calendar symbol in each event", async () => {
await testElementLength(".calendar .event .fa-calendar-alt", 0, "not");
});
});
describe("Custom configuration", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/custom.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/custom.js");
await helpers.getDocument();
});
it("should show the custom maximumEntries of 4", (done) => {
testElementLength(done, ".calendar .event", 4);
it("should show the custom maximumEntries of 4", async () => {
await testElementLength(".calendar .event", 4);
});
it("should show the custom calendar symbol in each event", (done) => {
testElementLength(done, ".calendar .event .fa-birthday-cake", 4);
it("should show the custom calendar symbol in each event", async () => {
await testElementLength(".calendar .event .fa-birthday-cake", 4);
});
it("should show two custom icons for repeating events", (done) => {
testElementLength(done, ".calendar .event .fa-undo", 2);
it("should show two custom icons for repeating events", async () => {
await testElementLength(".calendar .event .fa-undo", 2);
});
it("should show two custom icons for day events", (done) => {
testElementLength(done, ".calendar .event .fa-calendar-day", 2);
it("should show two custom icons for day events", async () => {
await testElementLength(".calendar .event .fa-calendar-day", 2);
});
});
describe("Recurring event", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/recurring.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/recurring.js");
await helpers.getDocument();
});
it("should show the recurring birthday event 6 times", (done) => {
testElementLength(done, ".calendar .event", 6);
it("should show the recurring birthday event 6 times", async () => {
await testElementLength(".calendar .event", 6);
});
});
process.setMaxListeners(0);
for (let i = -12; i < 12; i++) {
describe("Recurring event per timezone", () => {
beforeAll((done) => {
beforeAll(async () => {
Date.prototype.getTimezoneOffset = () => {
return i * 60;
};
helpers.startApplication("tests/configs/modules/calendar/recurring.js");
helpers.getDocument(done);
await helpers.startApplication("tests/configs/modules/calendar/recurring.js");
await helpers.getDocument();
});
it('should contain text "Mar 25th" in timezone UTC ' + -i, (done) => {
testTextContain(done, ".calendar", "Mar 25th");
it('should contain text "Mar 25th" in timezone UTC ' + -i, async () => {
await testTextContain(".calendar", "Mar 25th");
});
});
}
describe("Changed port", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/changed-port.js");
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/changed-port.js");
serverBasicAuth.listen(8010);
helpers.getDocument(done);
await helpers.getDocument();
});
afterAll((done) => {
serverBasicAuth.close(done());
afterAll(async () => {
await serverBasicAuth.close();
});
it("should return TestEvents", (done) => {
testElementLength(done, ".calendar .event", 0, "not");
it("should return TestEvents", async () => {
await testElementLength(".calendar .event", 0, "not");
});
});
describe("Basic auth", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/basic-auth.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/basic-auth.js");
await helpers.getDocument();
});
it("should return TestEvents", (done) => {
testElementLength(done, ".calendar .event", 0, "not");
it("should return TestEvents", async () => {
await testElementLength(".calendar .event", 0, "not");
});
});
describe("Basic auth by default", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/auth-default.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/auth-default.js");
await helpers.getDocument();
});
it("should return TestEvents", (done) => {
testElementLength(done, ".calendar .event", 0, "not");
it("should return TestEvents", async () => {
await testElementLength(".calendar .event", 0, "not");
});
});
describe("Basic auth backward compatibility configuration: DEPRECATED", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/old-basic-auth.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/old-basic-auth.js");
await helpers.getDocument();
});
it("should return TestEvents", (done) => {
testElementLength(done, ".calendar .event", 0, "not");
it("should return TestEvents", async () => {
await testElementLength(".calendar .event", 0, "not");
});
});
describe("Fail Basic auth", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/calendar/fail-basic-auth.js");
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/fail-basic-auth.js");
serverBasicAuth.listen(8020);
helpers.getDocument(done);
await helpers.getDocument();
});
afterAll((done) => {
serverBasicAuth.close(done());
afterAll(async () => {
await serverBasicAuth.close();
});
it("should show Unauthorized error", (done) => {
testTextContain(done, ".calendar", "Error in the calendar module. Authorization failed");
it("should show Unauthorized error", async () => {
await testTextContain(".calendar", "Error in the calendar module. Authorization failed");
});
});
});

View file

@ -1,73 +1,65 @@
const helpers = require("../global-setup");
const helpers = require("../helpers/global-setup");
describe("Clock set to spanish language module", () => {
afterAll(async () => {
await helpers.stopApplication();
});
const testMatch = (done, element, regex) => {
helpers.waitForElement(element).then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toMatch(regex);
});
};
describe("with default 24hr clock config", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/es/clock_24hr.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_24hr.js");
await helpers.getDocument();
});
it("shows date with correct format", (done) => {
it("shows date with correct format", async () => {
const dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \d{4}$/;
testMatch(done, ".clock .date", dateRegex);
await helpers.testMatch(".clock .date", dateRegex);
});
it("shows time in 24hr format", (done) => {
it("shows time in 24hr format", async () => {
const timeRegex = /^(?:2[0-3]|[01]\d):[0-5]\d[0-5]\d$/;
testMatch(done, ".clock .time", timeRegex);
await helpers.testMatch(".clock .time", timeRegex);
});
});
describe("with default 12hr clock config", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/es/clock_12hr.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_12hr.js");
await helpers.getDocument();
});
it("shows date with correct format", (done) => {
it("shows date with correct format", async () => {
const dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \d{4}$/;
testMatch(done, ".clock .date", dateRegex);
await helpers.testMatch(".clock .date", dateRegex);
});
it("shows time in 12hr format", (done) => {
it("shows time in 12hr format", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[ap]m$/;
testMatch(done, ".clock .time", timeRegex);
await helpers.testMatch(".clock .time", timeRegex);
});
});
describe("with showPeriodUpper config enabled", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/es/clock_showPeriodUpper.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_showPeriodUpper.js");
await helpers.getDocument();
});
it("shows 12hr time with upper case AM/PM", (done) => {
it("shows 12hr time with upper case AM/PM", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[AP]M$/;
testMatch(done, ".clock .time", timeRegex);
await helpers.testMatch(".clock .time", timeRegex);
});
});
describe("with showWeek config enabled", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/es/clock_showWeek.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_showWeek.js");
await helpers.getDocument();
});
it("shows week with correct format", (done) => {
it("shows week with correct format", async () => {
const weekRegex = /^Semana [0-9]{1,2}$/;
testMatch(done, ".clock .week", weekRegex);
await helpers.testMatch(".clock .week", weekRegex);
});
});
});

View file

@ -1,4 +1,4 @@
const helpers = require("../global-setup");
const helpers = require("../helpers/global-setup");
const moment = require("moment");
describe("Clock module", () => {
@ -6,118 +6,105 @@ describe("Clock module", () => {
await helpers.stopApplication();
});
const testMatch = (done, element, regex) => {
helpers.waitForElement(element).then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toMatch(regex);
});
};
describe("with default 24hr clock config", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/clock_24hr.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_24hr.js");
await helpers.getDocument();
});
it("should show the date in the correct format", (done) => {
it("should show the date in the correct format", async () => {
const dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}$/;
testMatch(done, ".clock .date", dateRegex);
await helpers.testMatch(".clock .date", dateRegex);
});
it("should show the time in 24hr format", (done) => {
it("should show the time in 24hr format", async () => {
const timeRegex = /^(?:2[0-3]|[01]\d):[0-5]\d[0-5]\d$/;
testMatch(done, ".clock .time", timeRegex);
await helpers.testMatch(".clock .time", timeRegex);
});
});
describe("with default 12hr clock config", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/clock_12hr.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_12hr.js");
await helpers.getDocument();
});
it("should show the date in the correct format", (done) => {
it("should show the date in the correct format", async () => {
const dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}$/;
testMatch(done, ".clock .date", dateRegex);
await helpers.testMatch(".clock .date", dateRegex);
});
it("should show the time in 12hr format", (done) => {
it("should show the time in 12hr format", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[ap]m$/;
testMatch(done, ".clock .time", timeRegex);
await helpers.testMatch(".clock .time", timeRegex);
});
});
describe("with showPeriodUpper config enabled", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/clock_showPeriodUpper.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showPeriodUpper.js");
await helpers.getDocument();
});
it("should show 12hr time with upper case AM/PM", (done) => {
it("should show 12hr time with upper case AM/PM", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[AP]M$/;
testMatch(done, ".clock .time", timeRegex);
await helpers.testMatch(".clock .time", timeRegex);
});
});
describe("with displaySeconds config disabled", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/clock_displaySeconds_false.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_displaySeconds_false.js");
await helpers.getDocument();
});
it("should show 12hr time without seconds am/pm", (done) => {
it("should show 12hr time without seconds am/pm", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[ap]m$/;
testMatch(done, ".clock .time", timeRegex);
await helpers.testMatch(".clock .time", timeRegex);
});
});
describe("with showTime config disabled", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/clock_showTime.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showTime.js");
await helpers.getDocument();
});
it("should not show the time when digital clock is shown", (done) => {
const elem = document.querySelector(".clock .digital .time");
done();
it("should not show the time when digital clock is shown", async () => {
const elem = await document.querySelector(".clock .digital .time");
expect(elem).toBe(null);
});
});
describe("with showWeek config enabled", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/clock_showWeek.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showWeek.js");
await helpers.getDocument();
});
it("should show the week in the correct format", (done) => {
it("should show the week in the correct format", async () => {
const weekRegex = /^Week [0-9]{1,2}$/;
testMatch(done, ".clock .week", weekRegex);
await helpers.testMatch(".clock .week", weekRegex);
});
it("should show the week with the correct number of week of year", (done) => {
it("should show the week with the correct number of week of year", async () => {
const currentWeekNumber = moment().week();
const weekToShow = "Week " + currentWeekNumber;
helpers.waitForElement(".clock .week").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toBe(weekToShow);
});
const elem = await helpers.waitForElement(".clock .week");
expect(elem).not.toBe(null);
expect(elem.textContent).toBe(weekToShow);
});
});
describe("with analog clock face enabled", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/clock/clock_analog.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_analog.js");
await helpers.getDocument();
});
it("should show the analog clock face", (done) => {
helpers.waitForElement(".clockCircle").then((elem) => {
done();
expect(elem).not.toBe(null);
});
it("should show the analog clock face", async () => {
const elem = helpers.waitForElement(".clockCircle");
expect(elem).not.toBe(null);
});
});
});

View file

@ -1,98 +1,55 @@
const helpers = require("../global-setup");
/**
* move similar tests in function doTest
*
* @param {string} done test done
* @param {Array} complimentsArray The array of compliments.
*/
const doTest = (done, complimentsArray) => {
helpers.waitForElement(".compliments").then((elem) => {
expect(elem).not.toBe(null);
helpers.waitForElement(".module-content").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(complimentsArray).toContain(elem.textContent);
});
});
};
const helpers = require("../helpers/global-setup");
describe("Compliments module", () => {
/**
* move similar tests in function doTest
*
* @param {Array} complimentsArray The array of compliments.
*/
const doTest = async (complimentsArray) => {
let elem = await helpers.waitForElement(".compliments");
expect(elem).not.toBe(null);
elem = await helpers.waitForElement(".module-content");
expect(elem).not.toBe(null);
expect(complimentsArray).toContain(elem.textContent);
};
afterAll(async () => {
await helpers.stopApplication();
});
describe("parts of days", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js");
helpers.getDocument(done);
});
it("if Morning compliments for that part of day", (done) => {
const hour = new Date().getHours();
if (hour >= 3 && hour < 12) {
// if morning check
doTest(done, ["Hi", "Good Morning", "Morning test"]);
} else {
done();
}
});
it("if Afternoon show Compliments for that part of day", (done) => {
const hour = new Date().getHours();
if (hour >= 12 && hour < 17) {
// if afternoon check
doTest(done, ["Hello", "Good Afternoon", "Afternoon test"]);
} else {
done();
}
});
it("if Evening show Compliments for that part of day", (done) => {
const hour = new Date().getHours();
if (!(hour >= 3 && hour < 12) && !(hour >= 12 && hour < 17)) {
// if evening check
doTest(done, ["Hello There", "Good Evening", "Evening test"]);
} else {
done();
}
});
});
describe("Feature anytime in compliments module", () => {
describe("Set anytime and empty compliments for morning, evening and afternoon ", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/compliments/compliments_anytime.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_anytime.js");
await helpers.getDocument();
});
it("Show anytime because if configure empty parts of day compliments and set anytime compliments", (done) => {
doTest(done, ["Anytime here"]);
it("Show anytime because if configure empty parts of day compliments and set anytime compliments", async () => {
await doTest(["Anytime here"]);
});
});
describe("Only anytime present in configuration compliments", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/compliments/compliments_only_anytime.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_only_anytime.js");
await helpers.getDocument();
});
it("Show anytime compliments", (done) => {
doTest(done, ["Anytime here"]);
it("Show anytime compliments", async () => {
await doTest(["Anytime here"]);
});
});
});
describe("Feature date in compliments module", () => {
describe("Set date and empty compliments for anytime, morning, evening and afternoon", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/compliments/compliments_date.js");
helpers.getDocument(done);
});
describe("remoteFile option", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_remote.js");
await helpers.getDocument();
});
it("Show happy new year compliment on new years day", (done) => {
doTest(done, ["Happy new year!"]);
});
it("should show compliments from a remote file", async () => {
await doTest(["Remote compliment file works!"]);
});
});
});

View file

@ -1,4 +1,4 @@
const helpers = require("../global-setup");
const helpers = require("../helpers/global-setup");
describe("Test helloworld module", () => {
afterAll(async () => {
@ -6,32 +6,28 @@ describe("Test helloworld module", () => {
});
describe("helloworld set config text", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/helloworld/helloworld.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/helloworld/helloworld.js");
await helpers.getDocument();
});
it("Test message helloworld module", (done) => {
helpers.waitForElement(".helloworld").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Test HelloWorld Module");
});
it("Test message helloworld module", async () => {
const elem = await helpers.waitForElement(".helloworld");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Test HelloWorld Module");
});
});
describe("helloworld default config text", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/helloworld/helloworld_default.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/helloworld/helloworld_default.js");
await helpers.getDocument();
});
it("Test message helloworld module", (done) => {
helpers.waitForElement(".helloworld").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Hello World!");
});
it("Test message helloworld module", async () => {
const elem = await helpers.waitForElement(".helloworld");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Hello World!");
});
});
});

View file

@ -1,4 +0,0 @@
const generateWeather = require("./weather_current");
const generateWeatherForecast = require("./weather_forecast");
module.exports = { generateWeather, generateWeatherForecast };

View file

@ -1,64 +0,0 @@
const _ = require("lodash");
/**
* @param {object} extendedData extra data to add to the default mock data
* @returns {string} mocked current weather data
*/
function generateWeather(extendedData = {}) {
return JSON.stringify(
_.merge(
{},
{
coord: {
lon: 11.58,
lat: 48.14
},
weather: [
{
id: 615,
main: "Snow",
description: "light rain and snow",
icon: "13d"
},
{
id: 500,
main: "Rain",
description: "light rain",
icon: "10d"
}
],
base: "stations",
main: {
temp: 1.49,
pressure: 1005,
humidity: 93.7,
temp_min: 1,
temp_max: 2
},
visibility: 7000,
wind: {
speed: 11.8,
deg: 250
},
clouds: {
all: 75
},
dt: 1547387400,
sys: {
type: 1,
id: 1267,
message: 0.0031,
country: "DE",
sunrise: 1547362817,
sunset: 1547394301
},
id: 2867714,
name: "Munich",
cod: 200
},
extendedData
)
);
}
module.exports = generateWeather;

View file

@ -1,4 +1,4 @@
const helpers = require("../global-setup");
const helpers = require("../helpers/global-setup");
describe("Newsfeed module", () => {
afterAll(async () => {
@ -6,86 +6,72 @@ describe("Newsfeed module", () => {
});
describe("Default configuration", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/newsfeed/default.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/default.js");
await helpers.getDocument();
});
it("should show the newsfeed title", (done) => {
helpers.waitForElement(".newsfeed .newsfeed-source").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Rodrigo Ramirez Blog");
});
it("should show the newsfeed title", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-source");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Rodrigo Ramirez Blog");
});
it("should show the newsfeed article", (done) => {
helpers.waitForElement(".newsfeed .newsfeed-title").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("QPanel");
});
it("should show the newsfeed article", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-title");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("QPanel");
});
it("should NOT show the newsfeed description", (done) => {
helpers.waitForElement(".newsfeed").then((elem) => {
const element = document.querySelector(".newsfeed .newsfeed-desc");
done();
expect(element).toBe(null);
});
it("should NOT show the newsfeed description", async () => {
await helpers.waitForElement(".newsfeed");
const element = document.querySelector(".newsfeed .newsfeed-desc");
expect(element).toBe(null);
});
});
describe("Custom configuration", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/newsfeed/prohibited_words.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/prohibited_words.js");
await helpers.getDocument();
});
it("should not show articles with prohibited words", (done) => {
helpers.waitForElement(".newsfeed .newsfeed-title").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Problema VirtualBox");
});
it("should not show articles with prohibited words", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-title");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Problema VirtualBox");
});
it("should show the newsfeed description", (done) => {
helpers.waitForElement(".newsfeed .newsfeed-desc").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent.length).not.toBe(0);
});
it("should show the newsfeed description", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-desc");
expect(elem).not.toBe(null);
expect(elem.textContent.length).not.toBe(0);
});
});
describe("Invalid configuration", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/newsfeed/incorrect_url.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/incorrect_url.js");
await helpers.getDocument();
});
it("should show malformed url warning", (done) => {
helpers.waitForElement(".newsfeed .small", "No news at the moment.").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Error in the Newsfeed module. Malformed url.");
});
it("should show malformed url warning", async () => {
const elem = await helpers.waitForElement(".newsfeed .small", "No news at the moment.");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Error in the Newsfeed module. Malformed url.");
});
});
describe("Ignore items", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/newsfeed/ignore_items.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/ignore_items.js");
await helpers.getDocument();
});
it("should show empty items info message", (done) => {
helpers.waitForElement(".newsfeed .small").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("No news at the moment.");
});
it("should show empty items info message", async () => {
const elem = await helpers.waitForElement(".newsfeed .small");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("No news at the moment.");
});
});
});

View file

@ -0,0 +1,84 @@
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
describe("Weather module", () => {
afterAll(async () => {
await helpers.stopApplication();
});
describe("Current weather", () => {
describe("Default configuration", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_default.js", {});
});
it("should render wind speed and wind direction", async () => {
await weatherFunc.getText(".weather .normal.medium span:nth-child(2)", "12 WSW");
});
it("should render temperature with icon", async () => {
await weatherFunc.getText(".weather .large.light span.bright", "1.5°");
});
it("should render feels like temperature", async () => {
await weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "Feels like -5.6°");
});
});
});
describe("Compliments Integration", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_compliments.js", {});
});
it("should render a compliment based on the current weather", async () => {
await weatherFunc.getText(".compliments .module-content span", "snow");
});
});
describe("Configuration Options", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_options.js", {});
});
it("should render windUnits in beaufort", async () => {
await weatherFunc.getText(".weather .normal.medium span:nth-child(2)", "6");
});
it("should render windDirection with an arrow", async () => {
const elem = await helpers.waitForElement(".weather .normal.medium sup i.fa-long-arrow-alt-up");
expect(elem).not.toBe(null);
expect(elem.outerHTML).toContain("transform:rotate(250deg);");
});
it("should render humidity", async () => {
await weatherFunc.getText(".weather .normal.medium span:nth-child(3)", "93.7");
});
it("should render degreeLabel for temp", async () => {
await weatherFunc.getText(".weather .large.light span.bright", "1°C");
});
it("should render degreeLabel for feels like", async () => {
await weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "Feels like -6°C");
});
});
describe("Current weather with imperial units", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/currentweather_units.js", {});
});
it("should render wind in imperial units", async () => {
await weatherFunc.getText(".weather .normal.medium span:nth-child(2)", "26 WSW");
});
it("should render temperatures in fahrenheit", async () => {
await weatherFunc.getText(".weather .large.light span.bright", "34,7°");
});
it("should render 'feels like' in fahrenheit", async () => {
await weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "Feels like 21,9°");
});
});
});

View file

@ -0,0 +1,96 @@
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
describe("Weather module: Weather Forecast", () => {
afterAll(async () => {
await helpers.stopApplication();
});
describe("Default configuration", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_default.js", {});
});
const days = ["Today", "Tomorrow", "Sun", "Mon", "Tue"];
for (const [index, day] of days.entries()) {
it("should render day " + day, async () => {
await weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`, day);
});
}
const icons = ["day-cloudy", "rain", "day-sunny", "day-sunny", "day-sunny"];
for (const [index, icon] of icons.entries()) {
it("should render icon " + icon, async () => {
const elem = await helpers.waitForElement(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(2) span.wi-${icon}`);
expect(elem).not.toBe(null);
});
}
const maxTemps = ["24.4°", "21.0°", "22.9°", "23.4°", "20.6°"];
for (const [index, temp] of maxTemps.entries()) {
it("should render max temperature " + temp, async () => {
await weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`, temp);
});
}
const minTemps = ["15.3°", "13.6°", "13.8°", "13.9°", "10.9°"];
for (const [index, temp] of minTemps.entries()) {
it("should render min temperature " + temp, async () => {
await weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(4)`, temp);
});
}
const opacities = [1, 1, 0.8, 0.5333333333333333, 0.2666666666666667];
for (const [index, opacity] of opacities.entries()) {
it("should render fading of rows with opacity=" + opacity, async () => {
const elem = await helpers.waitForElement(`.weather table.small tr:nth-child(${index + 1})`);
expect(elem).not.toBe(null);
expect(elem.outerHTML).toContain(`<tr style="opacity: ${opacity};">`);
});
}
});
describe("Absolute configuration", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_absolute.js", {});
});
const days = ["Fri", "Sat", "Sun", "Mon", "Tue"];
for (const [index, day] of days.entries()) {
it("should render day " + day, async () => {
await weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`, day);
});
}
});
describe("Configuration Options", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_options.js", {});
});
it("should render custom table class", async () => {
const elem = await helpers.waitForElement(".weather table.myTableClass");
expect(elem).not.toBe(null);
});
it("should render colored rows", async () => {
const table = await helpers.waitForElement(".weather table.myTableClass");
expect(table).not.toBe(null);
expect(table.rows).not.toBe(null);
expect(table.rows.length).toBe(5);
});
});
describe("Forecast weather units", () => {
beforeAll(async () => {
await weatherFunc.startApp("tests/configs/modules/weather/forecastweather_units.js", {});
});
const temperatures = ["75_9°", "69_8°", "73_2°", "74_1°", "69_1°"];
for (const [index, temp] of temperatures.entries()) {
it("should render custom decimalSymbol = '_' for temp " + temp, async () => {
await weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`, temp);
});
}
});
});

View file

@ -1,273 +0,0 @@
const moment = require("moment");
const helpers = require("../global-setup");
const path = require("path");
const fs = require("fs");
const { generateWeather, generateWeatherForecast } = require("./mocks");
describe("Weather module", () => {
/**
* @param {string} done test done
* @param {string} element css selector
* @param {string} result Expected text in given selector
*/
const getText = (done, element, result) => {
helpers.waitForElement(element).then((elem) => {
done();
expect(elem).not.toBe(null);
expect(
elem.textContent
.trim()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/[ ]+/g, " ")
).toBe(result);
});
};
/**
* @param {string} configFile path to configuration file
* @param {string} additionalMockData special data for mocking
* @param {string} callback callback
*/
const startApp = (configFile, additionalMockData, callback) => {
let mockWeather;
if (configFile.includes("forecast")) {
mockWeather = generateWeatherForecast(additionalMockData);
} else {
mockWeather = generateWeather(additionalMockData);
}
let content = fs.readFileSync(path.resolve(__dirname + "../../../../" + configFile)).toString();
content = content.replace("#####WEATHERDATA#####", mockWeather);
fs.writeFileSync(path.resolve(__dirname + "../../../../config/config.js"), content);
helpers.startApplication("");
helpers.getDocument(callback);
};
afterAll(async () => {
await helpers.stopApplication();
});
describe("Current weather", () => {
describe("Default configuration", () => {
beforeAll((done) => {
startApp("tests/configs/modules/weather/currentweather_default.js", {}, done);
});
it("should render wind speed and wind direction", (done) => {
getText(done, ".weather .normal.medium span:nth-child(2)", "6 WSW"); // now "12"
});
it("should render temperature with icon", (done) => {
getText(done, ".weather .large.light span.bright", "1.5°"); // now "1°C"
});
it("should render feels like temperature", (done) => {
getText(done, ".weather .normal.medium.feelslike span.dimmed", "Feels like -5.6°"); // now "Feels like -6°C"
});
});
describe("Default configuration with sunrise", () => {
beforeAll((done) => {
const sunrise = moment().startOf("day").unix();
const sunset = moment().startOf("day").unix();
startApp("tests/configs/modules/weather/currentweather_default.js", { sys: { sunrise, sunset } }, done);
});
it("should render sunrise", (done) => {
getText(done, ".weather .normal.medium span:nth-child(4)", "12:00 am");
});
});
describe("Default configuration with sunset", () => {
beforeAll((done) => {
const sunrise = moment().startOf("day").unix();
const sunset = moment().endOf("day").unix();
startApp("tests/configs/modules/weather/currentweather_default.js", { sys: { sunrise, sunset } }, done);
});
it("should render sunset", (done) => {
getText(done, ".weather .normal.medium span:nth-child(4)", "11:59 pm");
});
});
});
describe("Compliments Integration", () => {
beforeAll((done) => {
startApp("tests/configs/modules/weather/currentweather_compliments.js", {}, done);
});
it("should render a compliment based on the current weather", (done) => {
getText(done, ".compliments .module-content span", "snow");
});
});
describe("Configuration Options", () => {
beforeAll((done) => {
startApp("tests/configs/modules/weather/currentweather_options.js", {}, done);
});
it("should render useBeaufort = false", (done) => {
getText(done, ".weather .normal.medium span:nth-child(2)", "12");
});
it("should render showWindDirectionAsArrow = true", (done) => {
helpers.waitForElement(".weather .normal.medium sup i.fa-long-arrow-alt-up").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.outerHTML).toContain("transform:rotate(250deg);");
});
});
it("should render showHumidity = true", (done) => {
getText(done, ".weather .normal.medium span:nth-child(3)", "93.7");
});
it("should render degreeLabel = true for temp", (done) => {
getText(done, ".weather .large.light span.bright", "1°C");
});
it("should render degreeLabel = true for feels like", (done) => {
getText(done, ".weather .normal.medium.feelslike span.dimmed", "Feels like -6°C");
});
});
describe("Current weather units", () => {
beforeAll((done) => {
startApp(
"tests/configs/modules/weather/currentweather_units.js",
{
main: {
temp: (1.49 * 9) / 5 + 32,
temp_min: (1 * 9) / 5 + 32,
temp_max: (2 * 9) / 5 + 32
},
wind: {
speed: 11.8 * 2.23694
}
},
done
);
});
it("should render imperial units for wind", (done) => {
getText(done, ".weather .normal.medium span:nth-child(2)", "6 WSW");
});
it("should render imperial units for temp", (done) => {
getText(done, ".weather .large.light span.bright", "34,7°");
});
it("should render imperial units for feels like", (done) => {
getText(done, ".weather .normal.medium.feelslike span.dimmed", "Feels like 22,0°");
});
it("should render custom decimalSymbol = ',' for humidity", (done) => {
getText(done, ".weather .normal.medium span:nth-child(3)", "93,7");
});
it("should render custom decimalSymbol = ',' for temp", (done) => {
getText(done, ".weather .large.light span.bright", "34,7°");
});
it("should render custom decimalSymbol = ',' for feels like", (done) => {
getText(done, ".weather .normal.medium.feelslike span.dimmed", "Feels like 22,0°");
});
});
describe("Weather Forecast", () => {
describe("Default configuration", () => {
beforeAll((done) => {
startApp("tests/configs/modules/weather/forecastweather_default.js", {}, done);
});
const days = ["Today", "Tomorrow", "Sun", "Mon", "Tue"];
for (const [index, day] of days.entries()) {
it("should render day " + day, (done) => {
getText(done, `.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`, day);
});
}
const icons = ["day-cloudy", "rain", "day-sunny", "day-sunny", "day-sunny"];
for (const [index, icon] of icons.entries()) {
it("should render icon " + icon, (done) => {
helpers.waitForElement(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(2) span.wi-${icon}`).then((elem) => {
done();
expect(elem).not.toBe(null);
});
});
}
const maxTemps = ["24.4°", "21.0°", "22.9°", "23.4°", "20.6°"];
for (const [index, temp] of maxTemps.entries()) {
it("should render max temperature " + temp, (done) => {
getText(done, `.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`, temp);
});
}
const minTemps = ["15.3°", "13.6°", "13.8°", "13.9°", "10.9°"];
for (const [index, temp] of minTemps.entries()) {
it("should render min temperature " + temp, (done) => {
getText(done, `.weather table.small tr:nth-child(${index + 1}) td:nth-child(4)`, temp);
});
}
const opacities = [1, 1, 0.8, 0.5333333333333333, 0.2666666666666667];
for (const [index, opacity] of opacities.entries()) {
it("should render fading of rows with opacity=" + opacity, (done) => {
helpers.waitForElement(`.weather table.small tr:nth-child(${index + 1})`).then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.outerHTML).toContain(`<tr style="opacity: ${opacity};">`);
});
});
}
});
describe("Absolute configuration", () => {
beforeAll((done) => {
startApp("tests/configs/modules/weather/forecastweather_absolute.js", {}, done);
});
const days = ["Fri", "Sat", "Sun", "Mon", "Tue"];
for (const [index, day] of days.entries()) {
it("should render day " + day, (done) => {
getText(done, `.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`, day);
});
}
});
describe("Configuration Options", () => {
beforeAll((done) => {
startApp("tests/configs/modules/weather/forecastweather_options.js", {}, done);
});
it("should render custom table class", (done) => {
helpers.waitForElement(".weather table.myTableClass").then((elem) => {
done();
expect(elem).not.toBe(null);
});
});
it("should render colored rows", (done) => {
helpers.waitForElement(".weather table.myTableClass").then((table) => {
done();
expect(table).not.toBe(null);
expect(table.rows).not.toBe(null);
expect(table.rows.length).toBe(5);
});
});
});
describe("Forecast weather units", () => {
beforeAll((done) => {
startApp("tests/configs/modules/weather/forecastweather_units.js", {}, done);
});
const temperatures = ["24_4°", "21_0°", "22_9°", "23_4°", "20_6°"];
for (const [index, temp] of temperatures.entries()) {
it("should render custom decimalSymbol = '_' for temp " + temp, (done) => {
getText(done, `.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`, temp);
});
}
});
});
});

View file

@ -1,28 +1,24 @@
const helpers = require("./global-setup");
const helpers = require("./helpers/global-setup");
describe("Display of modules", () => {
beforeAll(function (done) {
helpers.startApplication("tests/configs/modules/display.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/display.js");
await helpers.getDocument();
});
afterAll(async () => {
await helpers.stopApplication();
});
it("should show the test header", (done) => {
helpers.waitForElement("#module_0_helloworld .module-header").then((elem) => {
done();
expect(elem).not.toBe(null);
// textContent gibt hier lowercase zurück, das uppercase wird durch css realisiert, was daher nicht in textContent landet
expect(elem.textContent).toBe("test_header");
});
it("should show the test header", async () => {
const elem = await helpers.waitForElement("#module_0_helloworld .module-header");
expect(elem).not.toBe(null);
// textContent gibt hier lowercase zurück, das uppercase wird durch css realisiert, was daher nicht in textContent landet
expect(elem.textContent).toBe("test_header");
});
it("should show no header if no header text is specified", (done) => {
helpers.waitForElement("#module_1_helloworld .module-header").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toBe("undefined");
});
it("should show no header if no header text is specified", async () => {
const elem = await helpers.waitForElement("#module_1_helloworld .module-header");
expect(elem).not.toBe(null);
expect(elem.textContent).toBe("undefined");
});
});

View file

@ -0,0 +1,23 @@
const helpers = require("./helpers/global-setup");
describe("Check configuration without modules", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/without_modules.js");
await helpers.getDocument();
});
afterAll(async () => {
await helpers.stopApplication();
});
it("Show the message MagicMirror² title", async () => {
const elem = await helpers.waitForElement("#module_1_helloworld .module-content");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("MagicMirror²");
});
it("Show the url of michael's website", async () => {
const elem = await helpers.waitForElement("#module_5_helloworld .module-content");
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("www.michaelteeuw.nl");
});
});

View file

@ -1,9 +1,9 @@
const helpers = require("./global-setup");
const helpers = require("./helpers/global-setup");
describe("Position of modules", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/modules/positions.js");
helpers.getDocument(done);
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/positions.js");
await helpers.getDocument();
});
afterAll(async () => {
await helpers.stopApplication();
@ -13,12 +13,10 @@ describe("Position of modules", () => {
for (const position of positions) {
const className = position.replace("_", ".");
it("should show text in " + position, (done) => {
helpers.waitForElement("." + className).then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Text in " + position);
});
it("should show text in " + position, async () => {
const elem = await helpers.waitForElement("." + className);
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("Text in " + position);
});
}
});

View file

@ -1,36 +0,0 @@
const fetch = require("fetch");
const helpers = require("./global-setup");
describe("port directive configuration", function () {
describe("Set port 8090", function () {
beforeAll(function () {
helpers.startApplication("tests/configs/port_8090.js");
});
afterAll(async function () {
await helpers.stopApplication();
});
it("should return 200", function (done) {
fetch("http://localhost:8090").then((res) => {
expect(res.status).toBe(200);
done();
});
});
});
describe("Set port 8100 on environment variable MM_PORT", function () {
beforeAll(function () {
helpers.startApplication("tests/configs/port_8090.js", (process.env.MM_PORT = 8100));
});
afterAll(async function () {
await helpers.stopApplication();
});
it("should return 200", function (done) {
fetch("http://localhost:8100").then((res) => {
expect(res.status).toBe(200);
done();
});
});
});
});

31
tests/e2e/port_spec.js Normal file
View file

@ -0,0 +1,31 @@
const helpers = require("./helpers/global-setup");
describe("port directive configuration", () => {
describe("Set port 8090", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/port_8090.js");
});
afterAll(async () => {
await helpers.stopApplication();
});
it("should return 200", async () => {
const res = await helpers.fetch("http://localhost:8090");
expect(res.status).toBe(200);
});
});
describe("Set port 8100 on environment variable MM_PORT", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/port_8090.js", (process.env.MM_PORT = 8100));
});
afterAll(async () => {
await helpers.stopApplication();
});
it("should return 200", async () => {
const res = await helpers.fetch("http://localhost:8100");
expect(res.status).toBe(200);
});
});
});

View file

@ -6,13 +6,13 @@ const { JSDOM } = require("jsdom");
const express = require("express");
const sinon = require("sinon");
describe("Translations", function () {
describe("Translations", () => {
let server;
beforeAll(function () {
beforeAll(() => {
const app = express();
app.use(helmet());
app.use(function (req, res, next) {
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
next();
});
@ -21,11 +21,11 @@ describe("Translations", function () {
server = app.listen(3000);
});
afterAll(function () {
afterAll(() => {
server.close();
});
it("should have a translation file in the specified path", function () {
it("should have a translation file in the specified path", () => {
for (let language in translations) {
const file = fs.statSync(translations[language]);
expect(file.isFile()).toBe(true);
@ -37,7 +37,7 @@ describe("Translations", function () {
beforeEach(() => {
dom = new JSDOM(
`<script>var Translator = {}; var Log = {log: function(){}}; var config = {language: 'de'};</script>\
`<script>var Translator = {}; var Log = {log: () => {}}; var config = {language: 'de'};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "class.js")}"></script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "module.js")}"></script>`,
{ runScripts: "dangerously", resources: "usable" }
@ -45,7 +45,7 @@ describe("Translations", function () {
});
it("should load translation file", (done) => {
dom.window.onload = async function () {
dom.window.onload = async () => {
const { Translator, Module, config } = dom.window;
config.language = "en";
Translator.load = sinon.stub().callsFake((_m, _f, _fb, callback) => callback());
@ -65,7 +65,7 @@ describe("Translations", function () {
});
it("should load translation + fallback file", (done) => {
dom.window.onload = async function () {
dom.window.onload = async () => {
const { Translator, Module } = dom.window;
Translator.load = sinon.stub().callsFake((_m, _f, _fb, callback) => callback());
@ -85,7 +85,7 @@ describe("Translations", function () {
});
it("should load translation fallback file", (done) => {
dom.window.onload = async function () {
dom.window.onload = async () => {
const { Translator, Module, config } = dom.window;
config.language = "--";
Translator.load = sinon.stub().callsFake((_m, _f, _fb, callback) => callback());
@ -105,7 +105,7 @@ describe("Translations", function () {
});
it("should load no file", (done) => {
dom.window.onload = async function () {
dom.window.onload = async () => {
const { Translator, Module } = dom.window;
Translator.load = sinon.stub();
@ -130,18 +130,18 @@ describe("Translations", function () {
}
};
describe("Parsing language files through the Translator class", function () {
describe("Parsing language files through the Translator class", () => {
for (let language in translations) {
it(`should parse ${language}`, function (done) {
it(`should parse ${language}`, (done) => {
const dom = new JSDOM(
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: function(){}};</script>\
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
Translator.load(mmm, translations[language], false, function () {
Translator.load(mmm, translations[language], false, () => {
expect(typeof Translator.translations[mmm.name]).toBe("object");
expect(Object.keys(Translator.translations[mmm.name]).length).toBeGreaterThanOrEqual(1);
done();
@ -151,27 +151,27 @@ describe("Translations", function () {
}
});
describe("Same keys", function () {
describe("Same keys", () => {
let base;
let missing = [];
beforeAll(function (done) {
beforeAll((done) => {
const dom = new JSDOM(
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: function(){}};</script>\
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
Translator.load(mmm, translations.de, false, function () {
Translator.load(mmm, translations.de, false, () => {
base = Object.keys(Translator.translations[mmm.name]).sort();
done();
});
};
});
afterAll(function () {
afterAll(() => {
console.log(missing);
});
@ -182,32 +182,32 @@ describe("Translations", function () {
continue;
}
describe(`Translation keys of ${language}`, function () {
describe(`Translation keys of ${language}`, () => {
let keys;
beforeAll(function (done) {
beforeAll((done) => {
const dom = new JSDOM(
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: function(){}};</script>\
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
Translator.load(mmm, translations[language], false, function () {
Translator.load(mmm, translations[language], false, () => {
keys = Object.keys(Translator.translations[mmm.name]).sort();
done();
});
};
});
it(`${language} keys should be in base`, function () {
keys.forEach(function (key) {
it(`${language} keys should be in base`, () => {
keys.forEach((key) => {
expect(base.indexOf(key)).toBeGreaterThanOrEqual(0);
});
});
it(`${language} should contain all base keys`, function () {
it(`${language} should contain all base keys`, () => {
// TODO: when all translations are fixed, use
// expect(keys).toEqual(base);
// instead of the try-catch-block

View file

@ -1,34 +1,29 @@
const fetch = require("fetch");
const helpers = require("./global-setup");
const helpers = require("./helpers/global-setup");
describe("Vendors", function () {
beforeAll(function () {
helpers.startApplication("tests/configs/default.js");
describe("Vendors", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/default.js");
});
afterAll(async function () {
afterAll(async () => {
await helpers.stopApplication();
});
describe("Get list vendors", function () {
describe("Get list vendors", () => {
const vendors = require(__dirname + "/../../vendor/vendor.js");
Object.keys(vendors).forEach((vendor) => {
it(`should return 200 HTTP code for vendor "${vendor}"`, function (done) {
it(`should return 200 HTTP code for vendor "${vendor}"`, async () => {
const urlVendor = "http://localhost:8080/vendor/" + vendors[vendor];
fetch(urlVendor).then((res) => {
expect(res.status).toBe(200);
done();
});
const res = await helpers.fetch(urlVendor);
expect(res.status).toBe(200);
});
});
Object.keys(vendors).forEach((vendor) => {
it(`should return 404 HTTP code for vendor https://localhost/"${vendor}"`, function (done) {
it(`should return 404 HTTP code for vendor https://localhost/"${vendor}"`, async () => {
const urlVendor = "http://localhost:8080/" + vendors[vendor];
fetch(urlVendor).then((res) => {
expect(res.status).toBe(404);
done();
});
const res = await helpers.fetch(urlVendor);
expect(res.status).toBe(404);
});
});
});

View file

@ -1,27 +0,0 @@
const helpers = require("./global-setup");
describe("Check configuration without modules", () => {
beforeAll((done) => {
helpers.startApplication("tests/configs/without_modules.js");
helpers.getDocument(done);
});
afterAll(async () => {
await helpers.stopApplication();
});
it("Show the message MagicMirror² title", (done) => {
helpers.waitForElement("#module_1_helloworld .module-content").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("MagicMirror²");
});
});
it("Show the text Michael's website", (done) => {
helpers.waitForElement("#module_5_helloworld .module-content").then((elem) => {
done();
expect(elem).not.toBe(null);
expect(elem.textContent).toContain("www.michaelteeuw.nl");
});
});
});

View file

@ -1,42 +1,34 @@
// see https://playwright.dev/docs/api/class-electronapplication
const helpers = require("./helpers/global-setup");
const events = require("events");
const { _electron: electron } = require("playwright");
let electronApp = null;
process.env.MM_CONFIG_FILE = "tests/configs/modules/display.js";
jest.retryTimes(3);
describe("Electron app environment", function () {
beforeEach(async function () {
electronApp = await electron.launch({ args: ["js/electron.js"] });
describe("Electron app environment", () => {
beforeEach(async () => {
await helpers.startApplication("tests/configs/modules/display.js");
});
afterEach(async function () {
await electronApp.close();
afterEach(async () => {
await helpers.stopApplication();
});
it("should open browserwindow", async function () {
expect(await electronApp.windows().length).toBe(1);
const page = await electronApp.firstWindow();
expect(await page.title()).toBe("MagicMirror²");
expect(await page.isVisible("body")).toBe(true);
const module = page.locator("#module_0_helloworld");
await module.waitFor();
it("should open browserwindow", async () => {
const module = await helpers.getElement("#module_0_helloworld");
expect(await module.textContent()).toContain("Test Display Header");
expect(await global.electronApp.windows().length).toBe(1);
});
});
describe("Development console tests", function () {
beforeEach(async function () {
electronApp = await electron.launch({ args: ["js/electron.js", "dev"] });
describe("Development console tests", () => {
beforeEach(async () => {
await helpers.startApplication("tests/configs/modules/display.js", null, ["js/electron.js", "dev"]);
});
afterEach(async function () {
await electronApp.close();
afterEach(async () => {
await helpers.stopApplication();
});
it("should open browserwindow and dev console", async function () {
const pageArray = await electronApp.windows();
it("should open browserwindow and dev console", async () => {
while (global.electronApp.windows().length < 2) await events.once(global.electronApp, "window");
const pageArray = await global.electronApp.windows();
expect(pageArray.length).toBe(2);
for (const page of pageArray) {
expect(["MagicMirror²", "DevTools"]).toContain(await page.title());

View file

@ -0,0 +1,46 @@
// see https://playwright.dev/docs/api/class-electronapplication
// https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728
// https://www.anycodings.com/1questions/958135/can-i-set-the-date-for-playwright-browser
const { _electron: electron } = require("playwright");
exports.startApplication = async (configFilename, systemDate = null, electronParams = ["js/electron.js"]) => {
global.electronApp = null;
global.page = null;
process.env.MM_CONFIG_FILE = configFilename;
process.env.TZ = "GMT";
jest.retryTimes(3);
global.electronApp = await electron.launch({ args: electronParams });
await global.electronApp.firstWindow();
for (const win of global.electronApp.windows()) {
const title = await win.title();
expect(["MagicMirror²", "DevTools"]).toContain(title);
if (title === "MagicMirror²") {
global.page = win;
if (systemDate) {
await global.page.evaluate((systemDate) => {
Date.now = () => {
return new Date(systemDate);
};
}, systemDate);
}
}
}
};
exports.stopApplication = async () => {
if (global.electronApp) {
await global.electronApp.close();
}
global.electronApp = null;
global.page = null;
};
exports.getElement = async (selector) => {
expect(global.page);
let elem = global.page.locator(selector);
await elem.waitFor();
expect(elem).not.toBe(null);
return elem;
};

View file

@ -0,0 +1,29 @@
const helpers = require("./global-setup");
const path = require("path");
const fs = require("fs");
const { generateWeather, generateWeatherForecast } = require("../../mocks/weather_test");
exports.getText = async (element, result) => {
const elem = await helpers.getElement(element);
await expect(elem).not.toBe(null);
const text = await elem.textContent();
await expect(
text
.trim()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/[ ]+/g, " ")
).toBe(result);
};
exports.startApp = async (configFile, systemDate) => {
let mockWeather;
if (configFile.includes("forecast")) {
mockWeather = generateWeatherForecast();
} else {
mockWeather = generateWeather();
}
let content = fs.readFileSync(path.resolve(__dirname + "../../../../" + configFile)).toString();
content = content.replace("#####WEATHERDATA#####", mockWeather);
fs.writeFileSync(path.resolve(__dirname + "../../../../config/config.js"), content);
await helpers.startApplication("", systemDate);
};

View file

@ -0,0 +1,32 @@
const helpers = require("../helpers/global-setup");
describe("Calendar module", () => {
/**
* move similar tests in function doTest
*
* @param {string} cssClass css selector
*/
const doTest = async (cssClass) => {
await helpers.getElement(".calendar");
await helpers.getElement(".module-content");
const events = await global.page.locator(".event");
const elem = await events.locator(cssClass);
expect(elem).not.toBe(null);
};
afterEach(async () => {
await helpers.stopApplication();
});
describe("Test css classes", () => {
it("has css class today", async () => {
await helpers.startApplication("tests/configs/modules/calendar/custom.js", "01 Jan 2030 12:30:00 GMT");
await doTest(".today");
});
it("has css class tomorrow", async () => {
await helpers.startApplication("tests/configs/modules/calendar/custom.js", "31 Dez 2029 12:30:00 GMT");
await doTest(".tomorrow");
});
});
});

View file

@ -0,0 +1,45 @@
const helpers = require("../helpers/global-setup");
describe("Compliments module", () => {
/**
* move similar tests in function doTest
*
* @param {Array} complimentsArray The array of compliments.
*/
const doTest = async (complimentsArray) => {
await helpers.getElement(".compliments");
const elem = await helpers.getElement(".module-content");
expect(elem).not.toBe(null);
expect(complimentsArray).toContain(await elem.textContent());
};
afterEach(async () => {
await helpers.stopApplication();
});
describe("parts of days", () => {
it("Morning compliments for that part of day", async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 10:00:00 GMT");
await doTest(["Hi", "Good Morning", "Morning test"]);
});
it("Afternoon show Compliments for that part of day", async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 15:00:00 GMT");
await doTest(["Hello", "Good Afternoon", "Afternoon test"]);
});
it("Evening show Compliments for that part of day", async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 20:00:00 GMT");
await doTest(["Hello There", "Good Evening", "Evening test"]);
});
});
describe("Feature date in compliments module", () => {
describe("Set date and empty compliments for anytime, morning, evening and afternoon", () => {
it("Show happy new year compliment on new years day", async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_date.js", "01 Jan 2022 10:00:00 GMT");
await doTest(["Happy new year!"]);
});
});
});
});

View file

@ -0,0 +1,28 @@
const helpers = require("../helpers/global-setup");
const weatherHelper = require("../helpers/weather-setup");
describe("Weather module", () => {
afterEach(async () => {
await helpers.stopApplication();
});
describe("Current weather with sunrise", () => {
beforeAll(async () => {
await weatherHelper.startApp("tests/configs/modules/weather/currentweather_default.js", "13 Jan 2019 00:30:00 GMT");
});
it("should render sunrise", async () => {
await weatherHelper.getText(".weather .normal.medium span:nth-child(4)", "7:00 am");
});
});
describe("Current weather with sunset", () => {
beforeAll(async () => {
await weatherHelper.startApp("tests/configs/modules/weather/currentweather_default.js", "13 Jan 2019 12:30:00 GMT");
});
it("should render sunset", async () => {
await weatherHelper.getText(".weather .normal.medium span:nth-child(4)", "3:45 pm");
});
});
});

View file

@ -0,0 +1,3 @@
{
"anytime": ["Remote compliment file works!"]
}

View file

@ -1,5 +1,5 @@
{
"LOADING": "Loading &hellip;",
"LOADING": "Loading ",
"TODAY": "Today",
"TOMORROW": "Tomorrow",

View file

@ -1,10 +1,71 @@
const _ = require("lodash");
/**
* @param {object} extendedData extra data to add to the default mock data
* @returns {string} mocked current weather data
*/
const generateWeather = (extendedData = {}) => {
return JSON.stringify(
_.merge(
{},
{
coord: {
lon: 11.58,
lat: 48.14
},
weather: [
{
id: 615,
main: "Snow",
description: "light rain and snow",
icon: "13d"
},
{
id: 500,
main: "Rain",
description: "light rain",
icon: "10d"
}
],
base: "stations",
main: {
temp: 1.49,
pressure: 1005,
humidity: 93.7,
temp_min: 1,
temp_max: 2
},
visibility: 7000,
wind: {
speed: 11.8,
deg: 250
},
clouds: {
all: 75
},
dt: 1547387400,
sys: {
type: 1,
id: 1267,
message: 0.0031,
country: "DE",
sunrise: 1547362817,
sunset: 1547394301
},
id: 2867714,
name: "Munich",
cod: 200
},
extendedData
)
);
};
/**
* @param {object} extendedData extra data to add to the default mock data
* @returns {string} mocked forecast weather data
*/
function generateWeatherForecast(extendedData = {}) {
const generateWeatherForecast = (extendedData = {}) => {
return JSON.stringify(
_.merge(
{},
@ -110,6 +171,6 @@ function generateWeatherForecast(extendedData = {}) {
extendedData
)
);
}
};
module.exports = generateWeatherForecast;
module.exports = { generateWeather, generateWeatherForecast };

View file

@ -1,39 +1,39 @@
const path = require("path");
const { JSDOM } = require("jsdom");
describe("File js/class", function () {
describe("Test function cloneObject", function () {
describe("File js/class", () => {
describe("Test function cloneObject", () => {
let clone;
let dom;
beforeAll(function (done) {
beforeAll((done) => {
dom = new JSDOM(
`<script>var Log = {log: function() {}};</script>\
`<script>var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "class.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { cloneObject } = dom.window;
clone = cloneObject;
done();
};
});
it("should clone object", function () {
it("should clone object", () => {
const expected = { name: "Rodrigo", web: "https://rodrigoramirez.com", project: "MagicMirror" };
const obj = clone(expected);
expect(obj).toEqual(expected);
expect(expected === obj).toBe(false);
});
it("should clone array", function () {
it("should clone array", () => {
const expected = [1, null, undefined, "TEST"];
const obj = clone(expected);
expect(obj).toEqual(expected);
expect(expected === obj).toBe(false);
});
it("should clone number", function () {
it("should clone number", () => {
let expected = 1;
let obj = clone(expected);
expect(obj).toBe(expected);
@ -43,25 +43,25 @@ describe("File js/class", function () {
expect(obj).toBe(expected);
});
it("should clone string", function () {
it("should clone string", () => {
const expected = "Perfect stranger";
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone undefined", function () {
it("should clone undefined", () => {
const expected = undefined;
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone null", function () {
it("should clone null", () => {
const expected = null;
const obj = clone(expected);
expect(obj).toBe(expected);
});
it("should clone nested object", function () {
it("should clone nested object", () => {
const expected = {
name: "fewieden",
link: "https://github.com/fewieden",
@ -83,21 +83,21 @@ describe("File js/class", function () {
expect(expected.properties.items[1] === obj.properties.items[1]).toBe(false);
});
describe("Test lockstring code", function () {
describe("Test lockstring code", () => {
let log;
beforeAll(function () {
beforeAll(() => {
log = dom.window.Log.log;
dom.window.Log.log = function cmp(str) {
dom.window.Log.log = (str) => {
expect(str).toBe("lockStrings");
};
});
afterAll(function () {
afterAll(() => {
dom.window.Log.log = log;
});
it("should clone object and log lockStrings", function () {
it("should clone object and log lockStrings", () => {
const expected = { name: "Module", lockStrings: "stringLock" };
const obj = clone(expected);
expect(obj).toEqual(expected);

View file

@ -1,11 +1,11 @@
const deprecated = require("../../../js/deprecated");
describe("Deprecated", function () {
it("should be an object", function () {
describe("Deprecated", () => {
it("should be an object", () => {
expect(typeof deprecated).toBe("object");
});
it("should contain configs array with deprecated options as strings", function () {
it("should contain configs array with deprecated options as strings", () => {
expect(Array.isArray(["deprecated.configs"])).toBe(true);
for (let option of deprecated.configs) {
expect(typeof option).toBe("string");

View file

@ -4,17 +4,17 @@ const { JSDOM } = require("jsdom");
const express = require("express");
const sockets = new Set();
describe("Translator", function () {
describe("Translator", () => {
let server;
beforeAll(function () {
beforeAll(() => {
const app = express();
app.use(helmet());
app.use(function (req, res, next) {
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
next();
});
app.use("/translations", express.static(path.join(__dirname, "..", "..", "..", "tests", "configs", "data")));
app.use("/translations", express.static(path.join(__dirname, "..", "..", "..", "tests", "mocks")));
server = app.listen(3000);
@ -23,7 +23,7 @@ describe("Translator", function () {
});
});
afterAll(function () {
afterAll(() => {
for (const socket of sockets) {
socket.destroy();
@ -33,7 +33,7 @@ describe("Translator", function () {
server.close();
});
describe("translate", function () {
describe("translate", () => {
const translations = {
"MMM-Module": {
Hello: "Hallo",
@ -70,16 +70,16 @@ describe("Translator", function () {
/**
* @param {object} Translator the global Translator object
*/
function setTranslations(Translator) {
const setTranslations = (Translator) => {
Translator.translations = translations;
Translator.coreTranslations = coreTranslations;
Translator.translationsFallback = translationsFallback;
Translator.coreTranslationsFallback = coreTranslationsFallback;
}
};
it("should return custom module translation", function (done) {
it("should return custom module translation", (done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "Hello");
@ -90,9 +90,9 @@ describe("Translator", function () {
};
});
it("should return core translation", function (done) {
it("should return core translation", (done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "FOO");
@ -103,9 +103,9 @@ describe("Translator", function () {
};
});
it("should return custom module translation fallback", function (done) {
it("should return custom module translation fallback", (done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "A key");
@ -114,9 +114,9 @@ describe("Translator", function () {
};
});
it("should return core translation fallback", function (done) {
it("should return core translation fallback", (done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Fallback");
@ -125,9 +125,9 @@ describe("Translator", function () {
};
});
it("should return translation with placeholder for missing variables", function (done) {
it("should return translation with placeholder for missing variables", (done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}");
@ -136,9 +136,9 @@ describe("Translator", function () {
};
});
it("should return key if no translation was found", function (done) {
it("should return key if no translation was found", (done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "MISSING");
@ -148,7 +148,7 @@ describe("Translator", function () {
});
});
describe("load", function () {
describe("load", () => {
const mmm = {
name: "TranslationTest",
file(file) {
@ -156,41 +156,41 @@ describe("Translator", function () {
}
};
it("should load translations", function (done) {
const dom = new JSDOM(`<script>var Log = {log: function(){}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
it("should load translations", (done) => {
const dom = new JSDOM(`<script>var Log = {log: () => {}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
const file = "TranslationTest.json";
const file = "translation_test.json";
Translator.load(mmm, file, false, function () {
const json = require(path.join(__dirname, "..", "..", "..", "tests", "configs", "data", file));
Translator.load(mmm, file, false, () => {
const json = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", file));
expect(Translator.translations[mmm.name]).toEqual(json);
done();
});
};
});
it("should load translation fallbacks", function (done) {
const dom = new JSDOM(`<script>var Log = {log: function(){}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
it("should load translation fallbacks", (done) => {
const dom = new JSDOM(`<script>var Log = {log: () => {}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
const file = "TranslationTest.json";
const file = "translation_test.json";
Translator.load(mmm, file, true, function () {
const json = require(path.join(__dirname, "..", "..", "..", "tests", "configs", "data", file));
Translator.load(mmm, file, true, () => {
const json = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", file));
expect(Translator.translationsFallback[mmm.name]).toEqual(json);
done();
});
};
});
it("should not load translations, if module fallback exists", function (done) {
const dom = new JSDOM(`<script>var Log = {log: function(){}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = function () {
it("should not load translations, if module fallback exists", (done) => {
const dom = new JSDOM(`<script>var Log = {log: () => {}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator, XMLHttpRequest } = dom.window;
const file = "TranslationTest.json";
const file = "translation_test.json";
XMLHttpRequest.prototype.send = function () {
XMLHttpRequest.prototype.send = () => {
throw "Shouldn't load files";
};
@ -198,7 +198,7 @@ describe("Translator", function () {
Hello: "Hallo"
};
Translator.load(mmm, file, false, function () {
Translator.load(mmm, file, false, () => {
expect(Translator.translations[mmm.name]).toBe(undefined);
expect(Translator.translationsFallback[mmm.name]).toEqual({
Hello: "Hallo"
@ -209,19 +209,19 @@ describe("Translator", function () {
});
});
describe("loadCoreTranslations", function () {
it("should load core translations and fallback", function (done) {
describe("loadCoreTranslations", () => {
it("should load core translations and fallback", (done) => {
const dom = new JSDOM(
`<script>var translations = {en: "http://localhost:3000/translations/en.json"}; var Log = {log: function(){}};</script>\
`<script>var translations = {en: "http://localhost:3000/translations/translation_test.json"}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
Translator.loadCoreTranslations("en");
const en = require(path.join(__dirname, "..", "..", "..", "tests", "configs", "data", "en.json"));
setTimeout(function () {
const en = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"));
setTimeout(() => {
expect(Translator.coreTranslations).toEqual(en);
expect(Translator.coreTranslationsFallback).toEqual(en);
done();
@ -229,18 +229,18 @@ describe("Translator", function () {
};
});
it("should load core fallback if language cannot be found", function (done) {
it("should load core fallback if language cannot be found", (done) => {
const dom = new JSDOM(
`<script>var translations = {en: "http://localhost:3000/translations/en.json"}; var Log = {log: function(){}};</script>\
`<script>var translations = {en: "http://localhost:3000/translations/translation_test.json"}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
Translator.loadCoreTranslations("MISSINGLANG");
const en = require(path.join(__dirname, "..", "..", "..", "tests", "configs", "data", "en.json"));
setTimeout(function () {
const en = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"));
setTimeout(() => {
expect(Translator.coreTranslations).toEqual({});
expect(Translator.coreTranslationsFallback).toEqual(en);
done();
@ -249,36 +249,36 @@ describe("Translator", function () {
});
});
describe("loadCoreTranslationsFallback", function () {
it("should load core translations fallback", function (done) {
describe("loadCoreTranslationsFallback", () => {
it("should load core translations fallback", (done) => {
const dom = new JSDOM(
`<script>var translations = {en: "http://localhost:3000/translations/en.json"}; var Log = {log: function(){}};</script>\
`<script>var translations = {en: "http://localhost:3000/translations/translation_test.json"}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
Translator.loadCoreTranslationsFallback();
const en = require(path.join(__dirname, "..", "..", "..", "tests", "configs", "data", "en.json"));
setTimeout(function () {
const en = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"));
setTimeout(() => {
expect(Translator.coreTranslationsFallback).toEqual(en);
done();
}, 500);
};
});
it("should load core fallback if language cannot be found", function (done) {
it("should load core fallback if language cannot be found", (done) => {
const dom = new JSDOM(
`<script>var translations = {}; var Log = {log: function(){}};</script>\
`<script>var translations = {}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = function () {
dom.window.onload = () => {
const { Translator } = dom.window;
Translator.loadCoreTranslations();
setTimeout(function () {
setTimeout(() => {
expect(Translator.coreTranslationsFallback).toEqual({});
done();
}, 500);

View file

@ -1,34 +1,34 @@
const Utils = require("../../../js/utils.js");
const colors = require("colors/safe");
describe("Utils", function () {
describe("colors", function () {
describe("Utils", () => {
describe("colors", () => {
const colorsEnabled = colors.enabled;
afterEach(function () {
afterEach(() => {
colors.enabled = colorsEnabled;
});
it("should have info, warn and error properties", function () {
it("should have info, warn and error properties", () => {
expect(Utils.colors).toHaveProperty("info");
expect(Utils.colors).toHaveProperty("warn");
expect(Utils.colors).toHaveProperty("error");
});
it("properties should be functions", function () {
it("properties should be functions", () => {
expect(typeof Utils.colors.info).toBe("function");
expect(typeof Utils.colors.warn).toBe("function");
expect(typeof Utils.colors.error).toBe("function");
});
it("should print colored message in supported consoles", function () {
it("should print colored message in supported consoles", () => {
colors.enabled = true;
expect(Utils.colors.info("some informations")).toBe("\u001b[34msome informations\u001b[39m");
expect(Utils.colors.warn("a warning")).toBe("\u001b[33ma warning\u001b[39m");
expect(Utils.colors.error("ERROR!")).toBe("\u001b[31mERROR!\u001b[39m");
});
it("should print message in unsupported consoles", function () {
it("should print message in unsupported consoles", () => {
colors.enabled = false;
expect(Utils.colors.info("some informations")).toBe("some informations");
expect(Utils.colors.warn("a warning")).toBe("a warning");

View file

@ -1,19 +1,19 @@
global.moment = require("moment");
describe("Functions into modules/default/calendar/calendar.js", function () {
describe("Functions into modules/default/calendar/calendar.js", () => {
// Fake for use by calendar.js
Module = {};
Module.definitions = {};
Module.register = function (name, moduleDefinition) {
Module.register = (name, moduleDefinition) => {
Module.definitions[name] = moduleDefinition;
};
beforeAll(function () {
beforeAll(() => {
// load calendar.js
require("../../../modules/default/calendar/calendar.js");
});
describe("capFirst", function () {
describe("capFirst", () => {
const words = {
rodrigo: "Rodrigo",
"123m": "123m",
@ -23,61 +23,61 @@ describe("Functions into modules/default/calendar/calendar.js", function () {
};
Object.keys(words).forEach((word) => {
it(`for '${word}' should return '${words[word]}'`, function () {
it(`for '${word}' should return '${words[word]}'`, () => {
expect(Module.definitions.calendar.capFirst(word)).toBe(words[word]);
});
});
});
describe("getLocaleSpecification", function () {
it("should return a valid moment.LocaleSpecification for a 12-hour format", function () {
describe("getLocaleSpecification", () => {
it("should return a valid moment.LocaleSpecification for a 12-hour format", () => {
expect(Module.definitions.calendar.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: "h:mm A" } });
});
it("should return a valid moment.LocaleSpecification for a 24-hour format", function () {
it("should return a valid moment.LocaleSpecification for a 24-hour format", () => {
expect(Module.definitions.calendar.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: "HH:mm" } });
});
it("should return the current system locale when called without timeFormat number", function () {
it("should return the current system locale when called without timeFormat number", () => {
expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat("LT") } });
});
it("should return a 12-hour longDateFormat when using the 'en' locale", function () {
it("should return a 12-hour longDateFormat when using the 'en' locale", () => {
const localeBackup = moment.locale();
moment.locale("en");
expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 12-hour longDateFormat when using the 'au' locale", function () {
it("should return a 12-hour longDateFormat when using the 'au' locale", () => {
const localeBackup = moment.locale();
moment.locale("au");
expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 12-hour longDateFormat when using the 'eg' locale", function () {
it("should return a 12-hour longDateFormat when using the 'eg' locale", () => {
const localeBackup = moment.locale();
moment.locale("eg");
expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'nl' locale", function () {
it("should return a 24-hour longDateFormat when using the 'nl' locale", () => {
const localeBackup = moment.locale();
moment.locale("nl");
expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'fr' locale", function () {
it("should return a 24-hour longDateFormat when using the 'fr' locale", () => {
const localeBackup = moment.locale();
moment.locale("fr");
expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'uk' locale", function () {
it("should return a 24-hour longDateFormat when using the 'uk' locale", () => {
const localeBackup = moment.locale();
moment.locale("uk");
expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
@ -85,43 +85,43 @@ describe("Functions into modules/default/calendar/calendar.js", function () {
});
});
describe("shorten", function () {
describe("shorten", () => {
const strings = {
" String with whitespace at the beginning that needs trimming": { length: 16, return: "String with whit&hellip;" },
"long string that needs shortening": { length: 16, return: "long string that&hellip;" },
" String with whitespace at the beginning that needs trimming": { length: 16, return: "String with whit" },
"long string that needs shortening": { length: 16, return: "long string that" },
"short string": { length: 16, return: "short string" },
"long string with no maxLength defined": { return: "long string with no maxLength defined" }
};
Object.keys(strings).forEach((string) => {
it(`for '${string}' should return '${strings[string].return}'`, function () {
it(`for '${string}' should return '${strings[string].return}'`, () => {
expect(Module.definitions.calendar.shorten(string, strings[string].length)).toBe(strings[string].return);
});
});
it("should return an empty string if shorten is called with a non-string", function () {
it("should return an empty string if shorten is called with a non-string", () => {
expect(Module.definitions.calendar.shorten(100)).toBe("");
});
it("should not shorten the string if shorten is called with a non-number maxLength", function () {
it("should not shorten the string if shorten is called with a non-number maxLength", () => {
expect(Module.definitions.calendar.shorten("This is a test string", "This is not a number")).toBe("This is a test string");
});
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)", function () {
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)", () => {
expect(Module.definitions.calendar.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", 20, true)).toBe(
"This is a <br>wrapEvent test. Should wrap <br>the string instead of <br>shorten it if called with <br>wrapEvent = true"
);
});
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)", function () {
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)", () => {
expect(Module.definitions.calendar.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", undefined, true)).toBe(
"This is a wrapEvent <br>test. Should wrap the string <br>instead of shorten it if called <br>with wrapEvent = true"
);
});
it("should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", function () {
it("should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", () => {
expect(Module.definitions.calendar.shorten("This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", undefined, true, 2)).toBe(
"This is a wrapEvent and <br>maxTitleLines test. Should wrap and &hellip;"
"This is a wrapEvent and <br>maxTitleLines test. Should wrap and "
);
});
});

Some files were not shown because too many files have changed in this diff Show more