pulll
main
matthes-me 2 years ago
parent 906f18a8ef
commit 0ff8686473
  1. 24
      mm/docker-compose.yaml
  2. 64
      mm/mounts/config/config.js
  3. 38
      mm/mounts/css/custom.css
  4. 31
      mm/mounts/css/custom.css.sample
  5. 29
      mm/mounts/css/custom.css.save
  6. 40
      mm/mounts/css/iframe.css
  7. 241
      mm/mounts/css/main.css
  8. 5
      mm/mounts/modules/default/alert/README.md
  9. 147
      mm/mounts/modules/default/alert/alert.js
  10. 156
      mm/mounts/modules/default/alert/notificationFx.js
  11. 5
      mm/mounts/modules/default/alert/styles/center.css
  12. 4
      mm/mounts/modules/default/alert/styles/left.css
  13. 929
      mm/mounts/modules/default/alert/styles/notificationFx.css
  14. 4
      mm/mounts/modules/default/alert/styles/right.css
  15. 18
      mm/mounts/modules/default/alert/templates/alert.njk
  16. 9
      mm/mounts/modules/default/alert/templates/notification.njk
  17. 4
      mm/mounts/modules/default/alert/translations/bg.json
  18. 4
      mm/mounts/modules/default/alert/translations/da.json
  19. 4
      mm/mounts/modules/default/alert/translations/de.json
  20. 4
      mm/mounts/modules/default/alert/translations/en.json
  21. 4
      mm/mounts/modules/default/alert/translations/es.json
  22. 4
      mm/mounts/modules/default/alert/translations/fr.json
  23. 4
      mm/mounts/modules/default/alert/translations/hu.json
  24. 4
      mm/mounts/modules/default/alert/translations/nl.json
  25. 4
      mm/mounts/modules/default/alert/translations/ru.json
  26. 4
      mm/mounts/modules/default/alert/translations/th.json
  27. 6
      mm/mounts/modules/default/calendar/README.md
  28. 24
      mm/mounts/modules/default/calendar/calendar.css
  29. 856
      mm/mounts/modules/default/calendar/calendar.js
  30. 155
      mm/mounts/modules/default/calendar/calendarfetcher.js
  31. 605
      mm/mounts/modules/default/calendar/calendarfetcherutils.js
  32. 117
      mm/mounts/modules/default/calendar/calendarutils.js
  33. 43
      mm/mounts/modules/default/calendar/debug.js
  34. 95
      mm/mounts/modules/default/calendar/node_helper.js
  35. 237
      mm/mounts/modules/default/calendar/windowsZones.json
  36. 6
      mm/mounts/modules/default/clock/README.md
  37. 306
      mm/mounts/modules/default/clock/clock.js
  38. 93
      mm/mounts/modules/default/clock/clock_styles.css
  39. 1
      mm/mounts/modules/default/clock/faces/face-001.svg
  40. 1
      mm/mounts/modules/default/clock/faces/face-002.svg
  41. 1
      mm/mounts/modules/default/clock/faces/face-003.svg
  42. 1
      mm/mounts/modules/default/clock/faces/face-004.svg
  43. 1
      mm/mounts/modules/default/clock/faces/face-005.svg
  44. 1
      mm/mounts/modules/default/clock/faces/face-006.svg
  45. 1
      mm/mounts/modules/default/clock/faces/face-007.svg
  46. 1
      mm/mounts/modules/default/clock/faces/face-008.svg
  47. 1
      mm/mounts/modules/default/clock/faces/face-009.svg
  48. 1
      mm/mounts/modules/default/clock/faces/face-010.svg
  49. 1
      mm/mounts/modules/default/clock/faces/face-011.svg
  50. 1
      mm/mounts/modules/default/clock/faces/face-012.svg
  51. 6
      mm/mounts/modules/default/compliments/README.md
  52. 181
      mm/mounts/modules/default/compliments/compliments.js
  53. 12
      mm/mounts/modules/default/defaultmodules.js
  54. 5
      mm/mounts/modules/default/helloworld/README.md
  55. 20
      mm/mounts/modules/default/helloworld/helloworld.js
  56. 5
      mm/mounts/modules/default/helloworld/helloworld.njk
  57. 6
      mm/mounts/modules/default/newsfeed/README.md
  58. 3
      mm/mounts/modules/default/newsfeed/fullarticle.njk
  59. 24
      mm/mounts/modules/default/newsfeed/newsfeed.css
  60. 409
      mm/mounts/modules/default/newsfeed/newsfeed.js
  61. 93
      mm/mounts/modules/default/newsfeed/newsfeed.njk
  62. 183
      mm/mounts/modules/default/newsfeed/newsfeedfetcher.js
  63. 86
      mm/mounts/modules/default/newsfeed/node_helper.js
  64. 3
      mm/mounts/modules/default/newsfeed/oldconfig.njk
  65. 6
      mm/mounts/modules/default/updatenotification/README.md
  66. 213
      mm/mounts/modules/default/updatenotification/git_helper.js
  67. 87
      mm/mounts/modules/default/updatenotification/node_helper.js
  68. 3
      mm/mounts/modules/default/updatenotification/updatenotification.css
  69. 100
      mm/mounts/modules/default/updatenotification/updatenotification.js
  70. 15
      mm/mounts/modules/default/updatenotification/updatenotification.njk
  71. 172
      mm/mounts/modules/default/utils.js
  72. 5
      mm/mounts/modules/default/weather/README.md
  73. 89
      mm/mounts/modules/default/weather/current.njk
  74. 52
      mm/mounts/modules/default/weather/forecast.njk
  75. 42
      mm/mounts/modules/default/weather/hourly.njk
  76. 3
      mm/mounts/modules/default/weather/providers/README.md
  77. 572
      mm/mounts/modules/default/weather/providers/envcanada.js
  78. 548
      mm/mounts/modules/default/weather/providers/openmeteo.js
  79. 448
      mm/mounts/modules/default/weather/providers/openweathermap.js
  80. 133
      mm/mounts/modules/default/weather/providers/pirateweather.js
  81. 334
      mm/mounts/modules/default/weather/providers/smhi.js
  82. 201
      mm/mounts/modules/default/weather/providers/ukmetoffice.js
  83. 271
      mm/mounts/modules/default/weather/providers/ukmetofficedatahub.js
  84. 208
      mm/mounts/modules/default/weather/providers/weatherbit.js
  85. 77
      mm/mounts/modules/default/weather/providers/weatherflow.js
  86. 367
      mm/mounts/modules/default/weather/providers/weathergov.js
  87. 632
      mm/mounts/modules/default/weather/providers/yr.js
  88. 49
      mm/mounts/modules/default/weather/weather.css
  89. 297
      mm/mounts/modules/default/weather/weather.js
  90. 136
      mm/mounts/modules/default/weather/weatherobject.js
  91. 168
      mm/mounts/modules/default/weather/weatherprovider.js
  92. 142
      mm/mounts/modules/default/weather/weatherutils.js
  93. BIN
      npm/data/database.sqlite
  94. 4
      npm/data/keys.json
  95. 78
      npm/data/nginx/proxy_host/1.conf
  96. 41
      npm/data/nginx/redirection_host/1.conf
  97. 19
      npm/docker-compose.yml.txt
  98. 23
      ontime/docker-compose.yml
  99. 23
      ontime/ontime-styles/override.css
  100. 22
      resteamer/docker-compose.yml
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,24 @@
version: '3.8'
services:
magicmirror:
container_name: mm
image: karsten13/magicmirror:latest
ports:
- "8080:8080"
volumes:
- ./mounts/config:/opt/magic_mirror/config
- ./mounts/modules:/opt/magic_mirror/modules
- ./mounts/css:/opt/magic_mirror/css
restart: unless-stopped
command:
- npm
- run
- server
networks:
- proxy
networks:
proxy:
external: true

@ -0,0 +1,64 @@
/* MagicMirror² Config Sample
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*
* For more information on how you can configure this file
* see https://docs.magicmirror.builders/configuration/introduction.html
* and https://docs.magicmirror.builders/modules/configuration.html
*
* You can use environment variables using a `config.js.template` file instead of `config.js`
* which will be converted to `config.js` while starting. For more information
* see https://docs.magicmirror.builders/configuration/introduction.html#enviromnent-variables
*/
let config = {
address: "0.0.0.0", // Address to listen on, can be:
// - "localhost", "127.0.0.1", "::1" to listen on loopback interface
// - another specific IPv4/6 to listen on a specific interface
// - "0.0.0.0", "::" to listen on any interface
// Default, when address config is left out or empty, is "localhost"
port: 8080,
basePath: "/", // The URL path where MagicMirror² is hosted. If you are using a Reverse proxy
// you must set the sub path here. basePath must end with a /
ipWhitelist: [], // Set [] to allow all IP addresses
// or add a specific IPv4 of 192.168.1.5 :
// ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.1.5"],
// or IPv4 range of 192.168.3.0 --> 192.168.3.15 use CIDR format :
// ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.3.0/28"],
useHttps: false, // Support HTTPS or not, default "false" will use HTTP
httpsPrivateKey: "", // HTTPS private key path, only require when useHttps is true
httpsCertificate: "", // HTTPS Certificate path, only require when useHttps is true
language: "de",
locale: "de-DE",
logLevel: ["INFO", "LOG", "WARN", "ERROR"], // Add "DEBUG" for even more logging
timeFormat: 24,
units: "metric",
modules: [
{
module: "alert",
},
{
module: "newsfeed",
position: "bottom_left",
config: {
feeds: [
{
title: "Mastodon",
url: "http://10.45.0.108:3000/?action=display&bridge=FeedMergeBridge&feed_name=KantineFestival&feed_1=https%3A%2F%2Fmastodon.social%2Ftags%2Fkantinefestival.rss&feed_2=https%3A%2F%2Fmastodon.social%2Ftags%2Fsubbotnik.rss&feed_3=https%3A%2F%2Fmastodon.social%2Ftags%2Fkantinesabot.rss&feed_4=https%3A%2F%2Fmastodon.social%2Ftags%2Fsubbotnikchemnitz.rss&feed_5=https%3A%2F%2Fmastodon.social%2Ftags%2Fchemnitz.rss&feed_6=&feed_7=&feed_8=&feed_9=&feed_10=&limit=&format=Atom"
}
],
showSourceTitle: true,
showPublishDate: true,
broadcastNewsFeeds: true,
broadcastNewsUpdates: true
}
},
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {module.exports = config;}

@ -0,0 +1,38 @@
:root {
--color-text: #999;
--color-text-dimmed: #666;
--color-text-bright: #fff;
--color-background: #00324D;
--font-primary: "Roboto Condensed";
--font-secondary: "Roboto";
--font-size: 20px;
--font-size-small: 0.75rem;
--gap-body-top: 0px;
--gap-body-right: 0px;
--gap-body-bottom: 0px;
--gap-body-left: 0px;
--gap-modules: 60px;
}
.newsfeed {
color: #eee;
position: relative;
font-size: 30px;
line-height: 20px;
background-color: #084263;
padding-top: 16px;
padding-right: 24px;
padding-bottom: 16px;
padding-left: 24px;
border-radius: 10px;
margin-left: 64px;
font-family: "Montserrat", sans-serif;
}
.region.bottom.left {
width: 843px;
hight: 300px;
margin-bottom: 20px;
}

@ -0,0 +1,31 @@
/* MagicMirror² Custom CSS Sample
*
* Change color and fonts here.
*
* Beware that properties cannot be unitless, so for example write '--gap-body: 0px;' instead of just '--gap-body: 0;'
*
* MIT Licensed.
*/
/* Uncomment and adjust accordingly if you want to import another font from the google-fonts-api: */
/* @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;300;400;700&display=swap'); */
:root {
--color-text: #999;
--color-text-dimmed: #666;
--color-text-bright: #fff;
--color-background: black;
--font-primary: "Roboto Condensed";
--font-secondary: "Roboto";
--font-size: 20px;
--font-size-small: 0.75rem;
--gap-body-top: 60px;
--gap-body-right: 60px;
--gap-body-bottom: 60px;
--gap-body-left: 60px;
--gap-modules: 30px;
}

@ -0,0 +1,29 @@
:root {
--color-text: #999;
--color-text-dimmed: #666;
--color-text-bright: #fff;
--color-background: #00324D;
--font-primary: "Roboto Condensed";
--font-secondary: "Roboto";
--font-size: 20px;
--font-size-small: 0.75rem;
--gap-body-top: 0px;
--gap-body-right: 0px;
--gap-body-bottom: 0px;
--gap-body-left: 0px;
--gap-modules: 0px;
}
.newsfeed {
color: #202020;
text-align: left;
background-color: #;
border-radius: 10px;
height: calc(var(--font-size) + 6px);
padding: 6px 6px 0;
font-weight: bold;
margin-left: 50%;
width: 50%;
height: 50%;
}

@ -0,0 +1,40 @@
body, html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#backgroundContainer {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
#backgroundFrame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: scale(2048/100); /* Skalieren auf 2048p */
transform-origin: 0 0; /* Die obere linke Ecke bleibt fixiert */
border: none;
}
#textOverlay {
position: absolute;
top: 50%;
left: 5%;
width: 50%;
height: 50%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: #ffffff;
font-size: 24px;
}

@ -0,0 +1,241 @@
:root {
--color-text: #999;
--color-text-dimmed: #666;
--color-text-bright: #fff;
--color-background: #000;
--font-primary: "Roboto Condensed";
--font-secondary: "Roboto";
--font-size: 20px;
--font-size-xsmall: 0.75rem;
--font-size-small: 1rem;
--font-size-medium: 1.5rem;
--font-size-large: 3.25rem;
--font-size-xlarge: 3.75rem;
--gap-body-top: 60px;
--gap-body-right: 60px;
--gap-body-bottom: 60px;
--gap-body-left: 60px;
--gap-modules: 30px;
}
html {
cursor: none;
overflow: hidden;
background: var(--color-background);
user-select: none;
font-size: var(--font-size);
}
::-webkit-scrollbar {
display: none;
}
body {
margin: var(--gap-body-top) var(--gap-body-right) var(--gap-body-bottom) var(--gap-body-left);
position: absolute;
height: calc(100% - var(--gap-body-top) - var(--gap-body-bottom));
width: calc(100% - var(--gap-body-right) - var(--gap-body-left));
background: var(--color-background);
color: var(--color-text);
font-family: var(--font-primary), sans-serif;
font-weight: 400;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/**
* Default styles.
*/
.dimmed {
color: var(--color-text-dimmed);
}
.normal {
color: var(--color-text);
}
.bright {
color: var(--color-text-bright);
}
.xsmall {
font-size: var(--font-size-xsmall);
line-height: 1.275;
}
.small {
font-size: var(--font-size-small);
line-height: 1.25;
}
.medium {
font-size: var(--font-size-medium);
line-height: 1.225;
}
.large {
font-size: var(--font-size-large);
line-height: 1;
}
.xlarge {
font-size: var(--font-size-xlarge);
line-height: 1;
letter-spacing: -3px;
}
.thin {
font-family: var(--font-secondary), sans-serif;
font-weight: 100;
}
.light {
font-family: var(--font-primary), sans-serif;
font-weight: 300;
}
.regular {
font-family: var(--font-primary), sans-serif;
font-weight: 400;
}
.bold {
font-family: var(--font-primary), sans-serif;
font-weight: 700;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
header {
text-transform: uppercase;
font-size: var(--font-size-xsmall);
font-family: var(--font-primary), Arial, Helvetica, sans-serif;
font-weight: 400;
border-bottom: 1px solid var(--color-text-dimmed);
line-height: 15px;
padding-bottom: 5px;
margin-bottom: 10px;
color: var(--color-text);
}
sup {
font-size: 50%;
line-height: 50%;
}
/**
* Module styles.
*/
.module {
margin-bottom: var(--gap-modules);
}
.module.hidden {
pointer-events: none;
}
.module:not(.hidden) {
pointer-events: auto;
}
.region.bottom .module {
margin-top: var(--gap-modules);
margin-bottom: 0;
}
.no-wrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pre-line {
white-space: pre-line;
}
/**
* Region Definitions.
*/
.region {
position: absolute;
}
.region.fullscreen {
position: absolute;
inset: calc(-1 * var(--gap-body-top)) calc(-1 * var(--gap-body-right)) calc(-1 * var(--gap-body-bottom)) calc(-1 * var(--gap-body-left));
pointer-events: none;
}
.region.right {
right: 0;
text-align: right;
}
.region.top {
top: 0;
}
.region.top.center,
.region.bottom.center {
left: 50%;
transform: translateX(-50%);
}
.region.top.right,
.region.top.left,
.region.top.center {
top: 100%;
}
.region.bottom {
bottom: 0;
}
.region.bottom.right,
.region.bottom.center,
.region.bottom.left {
bottom: 100%;
}
.region.bar {
width: 100%;
text-align: center;
}
.region.third,
.region.middle.center {
width: 100%;
text-align: center;
transform: translateY(-50%);
}
.region.upper.third {
top: 33%;
}
.region.middle.center {
top: 50%;
}
.region.lower.third {
top: 66%;
}
.region.left {
text-align: left;
}
.region table {
width: 100%;
border-spacing: 0;
border-collapse: separate;
}

@ -0,0 +1,5 @@
# Module: Alert
The alert module is one of the default modules of the MagicMirror². This module displays notifications from other modules.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/alert.html).

@ -0,0 +1,147 @@
/* global NotificationFx */
/* MagicMirror²
* Module: alert
*
* By Paul-Vincent Roll https://paulvincentroll.com/
* MIT Licensed.
*/
Module.register("alert", {
alerts: {},
defaults: {
effect: "slide", // scale|slide|genie|jelly|flip|bouncyflip|exploader
alert_effect: "jelly", // scale|slide|genie|jelly|flip|bouncyflip|exploader
display_time: 3500, // time a notification is displayed in seconds
position: "center",
welcome_message: false // shown at startup
},
getScripts() {
return ["notificationFx.js"];
},
getStyles() {
return ["font-awesome.css", this.file(`./styles/notificationFx.css`), this.file(`./styles/${this.config.position}.css`)];
},
getTranslations() {
return {
bg: "translations/bg.json",
da: "translations/da.json",
de: "translations/de.json",
en: "translations/en.json",
es: "translations/es.json",
fr: "translations/fr.json",
hu: "translations/hu.json",
nl: "translations/nl.json",
ru: "translations/ru.json",
th: "translations/th.json"
};
},
getTemplate(type) {
return `templates/${type}.njk`;
},
async start() {
Log.info(`Starting module: ${this.name}`);
if (this.config.effect === "slide") {
this.config.effect = `${this.config.effect}-${this.config.position}`;
}
if (this.config.welcome_message) {
const message = this.config.welcome_message === true ? this.translate("welcome") : this.config.welcome_message;
await this.showNotification({ title: this.translate("sysTitle"), message });
}
},
notificationReceived(notification, payload, sender) {
if (notification === "SHOW_ALERT") {
if (payload.type === "notification") {
this.showNotification(payload);
} else {
this.showAlert(payload, sender);
}
} else if (notification === "HIDE_ALERT") {
this.hideAlert(sender);
}
},
async showNotification(notification) {
const message = await this.renderMessage(notification.templateName || "notification", notification);
new NotificationFx({
message,
layout: "growl",
effect: this.config.effect,
ttl: notification.timer || this.config.display_time
}).show();
},
async showAlert(alert, sender) {
// If module already has an open alert close it
if (this.alerts[sender.name]) {
this.hideAlert(sender, false);
}
// Add overlay
if (!Object.keys(this.alerts).length) {
this.toggleBlur(true);
}
const message = await this.renderMessage(alert.templateName || "alert", alert);
// Store alert in this.alerts
this.alerts[sender.name] = new NotificationFx({
message,
effect: this.config.alert_effect,
ttl: alert.timer,
onClose: () => this.hideAlert(sender),
al_no: "ns-alert"
});
// Show alert
this.alerts[sender.name].show();
// Add timer to dismiss alert and overlay
if (alert.timer) {
setTimeout(() => {
this.hideAlert(sender);
}, alert.timer);
}
},
hideAlert(sender, close = true) {
// Dismiss alert and remove from this.alerts
if (this.alerts[sender.name]) {
this.alerts[sender.name].dismiss(close);
delete this.alerts[sender.name];
// Remove overlay
if (!Object.keys(this.alerts).length) {
this.toggleBlur(false);
}
}
},
renderMessage(type, data) {
return new Promise((resolve) => {
this.nunjucksEnvironment().render(this.getTemplate(type), data, function (err, res) {
if (err) {
Log.error("Failed to render alert", err);
}
resolve(res);
});
});
},
toggleBlur(add = false) {
const method = add ? "add" : "remove";
const modules = document.querySelectorAll(".module");
for (const module of modules) {
module.classList[method]("alert-blur");
}
}
});

@ -0,0 +1,156 @@
/**
* Based on work by
*
* notificationFx.js v1.0.0
* https://tympanus.net/codrops/
*
* Licensed under the MIT license.
* https://opensource.org/licenses/mit-license.php
*
* Copyright 2014, Codrops
* https://tympanus.net/codrops/
* @param {object} window The window object
*/
(function (window) {
/**
* Extend one object with another one
* @param {object} a The object to extend
* @param {object} b The object which extends the other, overwrites existing keys
* @returns {object} The merged object
*/
function extend(a, b) {
for (let key in b) {
if (b.hasOwnProperty(key)) {
a[key] = b[key];
}
}
return a;
}
/**
* NotificationFx constructor
* @param {object} options The configuration options
* @class
*/
function NotificationFx(options) {
this.options = extend({}, this.options);
extend(this.options, options);
this._init();
}
/**
* NotificationFx options
*/
NotificationFx.prototype.options = {
// element to which the notification will be appended
// defaults to the document.body
wrapper: document.body,
// the message
message: "yo!",
// layout type: growl|attached|bar|other
layout: "growl",
// effects for the specified layout:
// for growl layout: scale|slide|genie|jelly
// for attached layout: flip|bouncyflip
// for other layout: boxspinner|cornerexpand|loadingcircle|thumbslider
// ...
effect: "slide",
// notice, warning, error, success
// will add class ns-type-warning, ns-type-error or ns-type-success
type: "notice",
// if the user doesn´t close the notification then we remove it
// after the following time
ttl: 6000,
al_no: "ns-box",
// callbacks
onClose: function () {
return false;
},
onOpen: function () {
return false;
}
};
/**
* Initialize and cache some vars
*/
NotificationFx.prototype._init = function () {
// create HTML structure
this.ntf = document.createElement("div");
this.ntf.className = `${this.options.al_no} ns-${this.options.layout} ns-effect-${this.options.effect} ns-type-${this.options.type}`;
let strinner = '<div class="ns-box-inner">';
strinner += this.options.message;
strinner += "</div>";
this.ntf.innerHTML = strinner;
// append to body or the element specified in options.wrapper
this.options.wrapper.insertBefore(this.ntf, this.options.wrapper.nextSibling);
// dismiss after [options.ttl]ms
if (this.options.ttl) {
this.dismissttl = setTimeout(() => {
if (this.active) {
this.dismiss();
}
}, this.options.ttl);
}
// init events
this._initEvents();
};
/**
* Init events
*/
NotificationFx.prototype._initEvents = function () {
// dismiss notification by tapping on it if someone has a touchscreen
this.ntf.querySelector(".ns-box-inner").addEventListener("click", () => {
this.dismiss();
});
};
/**
* Show the notification
*/
NotificationFx.prototype.show = function () {
this.active = true;
this.ntf.classList.remove("ns-hide");
this.ntf.classList.add("ns-show");
this.options.onOpen();
};
/**
* Dismiss the notification
* @param {boolean} [close] call the onClose callback at the end
*/
NotificationFx.prototype.dismiss = function (close = true) {
this.active = false;
clearTimeout(this.dismissttl);
this.ntf.classList.remove("ns-show");
setTimeout(() => {
this.ntf.classList.add("ns-hide");
// callback
if (close) this.options.onClose();
}, 25);
// after animation ends remove ntf from the DOM
const onEndAnimationFn = (ev) => {
if (ev.target !== this.ntf) {
return false;
}
this.ntf.removeEventListener("animationend", onEndAnimationFn);
if (ev.target.parentNode === this.options.wrapper) {
this.options.wrapper.removeChild(this.ntf);
}
};
this.ntf.addEventListener("animationend", onEndAnimationFn);
};
/**
* Add to global namespace
*/
window.NotificationFx = NotificationFx;
})(window);

@ -0,0 +1,5 @@
.ns-box {
margin-left: auto;
margin-right: auto;
text-align: center;
}

@ -0,0 +1,4 @@
.ns-box {
margin-right: auto;
text-align: left;
}

@ -0,0 +1,929 @@
/* Based on work by https://tympanus.net/codrops/licensing/ */
.ns-box {
background-color: rgb(0 0 0 / 93%);
padding: 17px;
line-height: 1.4;
margin-bottom: 10px;
z-index: 1;
font-size: 70%;
position: relative;
display: table;
word-wrap: break-word;
max-width: 100%;
border-width: 1px;
border-radius: 5px;
border-style: solid;
border-color: var(--color-text-dimmed);
}
.ns-alert {
border-style: solid;
border-color: var(--color-text-bright);
padding: 17px;
line-height: 1.4;
margin-bottom: 10px;
z-index: 3;
color: var(--color-text-bright);
font-size: 70%;
position: fixed;
text-align: center;
right: 0;
left: 0;
margin-right: auto;
margin-left: auto;
top: 40%;
width: 40%;
height: auto;
word-wrap: break-word;
border-radius: 20px;
}
.alert-blur {
filter: blur(2px) brightness(50%);
}
[class^="ns-effect-"].ns-growl.ns-hide,
[class*=" ns-effect-"].ns-growl.ns-hide {
animation-direction: reverse;
}
.ns-effect-flip {
transform-origin: 50% 100%;
backface-visibility: hidden;
}
.ns-effect-flip.ns-show,
.ns-effect-flip.ns-hide {
animation-name: anim-flip-front;
animation-duration: 0.3s;
}
.ns-effect-flip.ns-hide {
animation-name: anim-flip-back;
}
@keyframes anim-flip-front {
0% {
transform: perspective(1000px) rotate3d(1, 0, 0, -90deg);
}
100% {
transform: perspective(1000px);
}
}
@keyframes anim-flip-back {
0% {
transform: perspective(1000px) rotate3d(1, 0, 0, 90deg);
}
100% {
transform: perspective(1000px);
}
}
.ns-effect-bouncyflip.ns-show,
.ns-effect-bouncyflip.ns-hide {
animation-name: flip-in-x;
animation-duration: 0.8s;
}
@keyframes flip-in-x {
0% {
transform: perspective(400px) rotate3d(1, 0, 0, -90deg);
transition-timing-function: ease-in;
}
40% {
transform: perspective(400px) rotate3d(1, 0, 0, 20deg);
transition-timing-function: ease-out;
}
60% {
transform: perspective(400px) rotate3d(1, 0, 0, -10deg);
transition-timing-function: ease-in;
opacity: 1;
}
80% {
transform: perspective(400px) rotate3d(1, 0, 0, 5deg);
transition-timing-function: ease-out;
}
100% {
transform: perspective(400px);
}
}
.ns-effect-bouncyflip.ns-hide {
animation-name: flip-in-x-simple;
animation-duration: 0.3s;
}
@keyframes flip-in-x-simple {
0% {
transform: perspective(400px) rotate3d(1, 0, 0, -90deg);
transition-timing-function: ease-in;
}
100% {
transform: perspective(400px);
}
}
.ns-effect-exploader {
transform-origin: 0 0;
}
.ns-effect-exploader p {
padding: 0.25em 2em 0.25em 3em;
}
.ns-effect-exploader.ns-show {
animation-name: anim-load;
animation-duration: 1s;
}
@keyframes anim-load {
0% {
opacity: 1;
transform: scale3d(0, 0.3, 1);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
.ns-effect-exploader.ns-hide {
animation-name: anim-fade;
animation-duration: 0.3s;
}
.ns-effect-exploader.ns-show .ns-box-inner,
.ns-effect-exploader.ns-show .ns-close {
animation-fill-mode: both;
animation-duration: 0.3s;
animation-delay: 0.6s;
}
.ns-effect-exploader.ns-show .ns-close {
animation-name: anim-fade;
}
.ns-effect-exploader.ns-show .ns-box-inner {
animation-name: anim-fade-move;
animation-timing-function: ease-out;
}
@keyframes anim-fade-move {
0% {
opacity: 0;
transform: translate3d(0, 10px, 0);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes anim-fade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.ns-effect-scale.ns-show,
.ns-effect-scale.ns-hide {
animation-name: anim-scale;
animation-duration: 0.25s;
}
@keyframes anim-scale {
0% {
opacity: 0;
transform: translate3d(0, 40px, 0) scale3d(0.1, 0.6, 1);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
}
.ns-effect-jelly.ns-show {
animation-name: anim-jelly;
animation-duration: 1s;
animation-timing-function: linear;
}
.ns-effect-jelly.ns-hide {
animation-name: anim-fade;
animation-duration: 0.3s;
}
@keyframes anim-fade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes anim-jelly {
0% {
transform: matrix3d(0.7, 0, 0, 0, 0, 0.7, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
2.083333% {
transform: matrix3d(0.7527, 0, 0, 0, 0, 0.7634, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
4.166667% {
transform: matrix3d(0.8107, 0, 0, 0, 0, 0.8454, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
6.25% {
transform: matrix3d(0.8681, 0, 0, 0, 0, 0.929, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
8.333333% {
transform: matrix3d(0.9204, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
10.416667% {
transform: matrix3d(0.9648, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
12.5% {
transform: matrix3d(1, 0, 0, 0, 0, 1.082, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
14.583333% {
transform: matrix3d(1.0256, 0, 0, 0, 0, 1.0915, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
16.666667% {
transform: matrix3d(1.0423, 0, 0, 0, 0, 1.0845, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
18.75% {
transform: matrix3d(1.051, 0, 0, 0, 0, 1.0667, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
20.833333% {
transform: matrix3d(1.0533, 0, 0, 0, 0, 1.0436, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
22.916667% {
transform: matrix3d(1.0508, 0, 0, 0, 0, 1.0201, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
25% {
transform: matrix3d(1.0449, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
27.083333% {
transform: matrix3d(1.037, 0, 0, 0, 0, 0.9853, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
29.166667% {
transform: matrix3d(1.0283, 0, 0, 0, 0, 0.9769, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
31.25% {
transform: matrix3d(1.0197, 0, 0, 0, 0, 0.9742, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
33.333333% {
transform: matrix3d(1.0119, 0, 0, 0, 0, 0.9762, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
35.416667% {
transform: matrix3d(1.0053, 0, 0, 0, 0, 0.9812, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
37.5% {
transform: matrix3d(1, 0, 0, 0, 0, 0.9877, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
39.583333% {
transform: matrix3d(0.9962, 0, 0, 0, 0, 0.9943, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
41.666667% {
transform: matrix3d(0.9937, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
43.75% {
transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0041, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
45.833333% {
transform: matrix3d(0.992, 0, 0, 0, 0, 1.0065, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
47.916667% {
transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0073, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
50% {
transform: matrix3d(0.9933, 0, 0, 0, 0, 1.0067, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
52.083333% {
transform: matrix3d(0.9945, 0, 0, 0, 0, 1.0053, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
54.166667% {
transform: matrix3d(0.9958, 0, 0, 0, 0, 1.0035, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
56.25% {
transform: matrix3d(0.997, 0, 0, 0, 0, 1.002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
58.333333% {
transform: matrix3d(0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
60.416667% {
transform: matrix3d(0.9992, 0, 0, 0, 0, 0.9989, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
62.5% {
transform: matrix3d(1, 0, 0, 0, 0, 0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
64.583333% {
transform: matrix3d(1.0006, 0, 0, 0, 0, 0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
66.666667% {
transform: matrix3d(1.001, 0, 0, 0, 0, 0.9981, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
68.75% {
transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9985, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
70.833333% {
transform: matrix3d(1.0012, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
72.916667% {
transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9996, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
75% {
transform: matrix3d(1.001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
77.083333% {
transform: matrix3d(1.0008, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
79.166667% {
transform: matrix3d(1.0006, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
81.25% {
transform: matrix3d(1.0004, 0, 0, 0, 0, 1.0006, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
83.333333% {
transform: matrix3d(1.0003, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
85.416667% {
transform: matrix3d(1.0001, 0, 0, 0, 0, 1.0004, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
87.5% {
transform: matrix3d(1, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
89.583333% {
transform: matrix3d(0.9999, 0, 0, 0, 0, 1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
91.666667% {
transform: matrix3d(0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
93.75% {
transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
95.833333% {
transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
97.916667% {
transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
100% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
}
.ns-effect-slide-left.ns-show {
animation-name: anim-slide-elastic-left;
animation-duration: 1s;
animation-timing-function: linear;
}
@keyframes anim-slide-elastic-left {
0% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1000, 0, 0, 1);
}
1.666667% {
transform: matrix3d(1.9293, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -739.2681, 0, 0, 1);
}
3.333333% {
transform: matrix3d(1.9699, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -521.8255, 0, 0, 1);
}
5% {
transform: matrix3d(1.709, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -349.2612, 0, 0, 1);
}
6.666667% {
transform: matrix3d(1.424, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -218.324, 0, 0, 1);
}
8.333333% {
transform: matrix3d(1.2107, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -123.2985, 0, 0, 1);
}
10% {
transform: matrix3d(1.0817, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -57.5927, 0, 0, 1);
}
11.666667% {
transform: matrix3d(1.017, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -14.7237, 0, 0, 1);
}
13.333333% {
transform: matrix3d(0.9906, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.1279, 0, 0, 1);
}
15% {
transform: matrix3d(0.9848, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 24.8634, 0, 0, 1);
}
16.666667% {
transform: matrix3d(0.9872, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.405, 0, 0, 1);
}
18.333333% {
transform: matrix3d(0.992, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.7528, 0, 0, 1);
}
20% {
transform: matrix3d(0.9954, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 28.1014, 0, 0, 1);
}
21.666667% {
transform: matrix3d(0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 23.9827, 0, 0, 1);
}
23.333333% {
transform: matrix3d(0.9994, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 19.4075, 0, 0, 1);
}
25% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 14.9956, 0, 0, 1);
}
26.666667% {
transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.0858, 0, 0, 1);
}
28.333333% {
transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 7.8251, 0, 0, 1);
}
30% {
transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5.2374, 0, 0, 1);
}
31.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3.2739, 0, 0, 1);
}
33.333333% {
transform: matrix3d(1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.8489, 0, 0, 1);
}
35% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.8636, 0, 0, 1);
}
36.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.2208, 0, 0, 1);
}
38.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1669, 0, 0, 1);
}
40% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3728, 0, 0, 1);
}
41.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4559, 0, 0, 1);
}
43.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4612, 0, 0, 1);
}
45% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.421, 0, 0, 1);
}
46.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3596, 0, 0, 1);
}
48.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.291, 0, 0, 1);
}
50% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.2249, 0, 0, 1);
}
51.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1662, 0, 0, 1);
}
53.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1173, 0, 0, 1);
}
55% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0785, 0, 0, 1);
}
56.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0491, 0, 0, 1);
}
58.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0277, 0, 0, 1);
}
60% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.013, 0, 0, 1);
}
61.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0033, 0, 0, 1);
}
63.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1);
}
65% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0056, 0, 0, 1);
}
66.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0068, 0, 0, 1);
}
68.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0069, 0, 0, 1);
}
70% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0063, 0, 0, 1);
}
71.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0054, 0, 0, 1);
}
73.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0044, 0, 0, 1);
}
75% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0034, 0, 0, 1);
}
76.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1);
}
78.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0018, 0, 0, 1);
}
80% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0012, 0, 0, 1);
}
81.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0007, 0, 0, 1);
}
83.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0004, 0, 0, 1);
}
85% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0002, 0, 0, 1);
}
86.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0001, 0, 0, 1);
}
88.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
}
90% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
}
91.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
}
93.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
}
95% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
}
96.666667% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
}
98.333333% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
}
100% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
}
.ns-effect-slide-left.ns-hide {
animation-name: anim-slide-left;
animation-duration: 0.25s;
}
@keyframes anim-slide-left {
0% {
transform: translate3d(-30px, 0, 0) translate3d(-100%, 0, 0);
}
100% {
transform: translate3d(0, 0, 0);
}
}
.ns-effect-slide-right.ns-show {
animation: anim-slide-elastic-right 2000ms linear both;
}
@keyframes anim-slide-elastic-right {
0% {
transform: matrix3d(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1000, 0, 0, 1);
}
2.15% {
transform: matrix3d(1.486, 0, 0, 0, 0, 0.514, 0, 0, 0, 0, 1, 0, 664.594, 0, 0, 1);
}
4.1% {
transform: matrix3d(1.147, 0, 0, 0, 0, 0.853, 0, 0, 0, 0, 1, 0, 419.708, 0, 0, 1);
}
4.3% {
transform: matrix3d(1.121, 0, 0, 0, 0, 0.879, 0, 0, 0, 0, 1, 0, 398.136, 0, 0, 1);
}
6.46% {
transform: matrix3d(0.948, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 206.714, 0, 0, 1);
}
8.11% {
transform: matrix3d(0.908, 0, 0, 0, 0, 1.092, 0, 0, 0, 0, 1, 0, 105.491, 0, 0, 1);
}
8.61% {
transform: matrix3d(0.907, 0, 0, 0, 0, 1.093, 0, 0, 0, 0, 1, 0, 81.572, 0, 0, 1);
}
12.11% {
transform: matrix3d(0.95, 0, 0, 0, 0, 1.05, 0, 0, 0, 0, 1, 0, -18.434, 0, 0, 1);
}
14.16% {
transform: matrix3d(0.979, 0, 0, 0, 0, 1.021, 0, 0, 0, 0, 1, 0, -38.734, 0, 0, 1);
}
16.12% {
transform: matrix3d(0.997, 0, 0, 0, 0, 1.003, 0, 0, 0, 0, 1, 0, -43.356, 0, 0, 1);
}
19.72% {
transform: matrix3d(1.006, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, -34.155, 0, 0, 1);
}
27.23% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -7.839, 0, 0, 1);
}
30.83% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1.951, 0, 0, 1);
}
38.34% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.037, 0, 0, 1);
}
41.99% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.812, 0, 0, 1);
}
50% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.159, 0, 0, 1);
}
60.56% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.025, 0, 0, 1);
}
82.78% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.001, 0, 0, 1);
}
100% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
}
.ns-effect-slide-right.ns-hide {
animation-name: anim-slide-right;
animation-duration: 0.25s;
}
@keyframes anim-slide-right {
0% {
transform: translate3d(30px, 0, 0) translate3d(100%, 0, 0);
}
100% {
transform: translate3d(0, 0, 0);
}
}
.ns-effect-slide-center.ns-show {
animation: anim-slide-elastic-center 2000ms linear both;
}
@keyframes anim-slide-elastic-center {
0% {
transform: matrix3d(1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1, 0, 0, -300, 0, 1);
}
2.15% {
transform: matrix3d(1, 0, 0, 0, 0, 1.971, 0, 0, 0, 0, 1, 0, 0, -199.378, 0, 1);
}
4.1% {
transform: matrix3d(1, 0, 0, 0, 0, 1.294, 0, 0, 0, 0, 1, 0, 0, -125.912, 0, 1);
}
4.3% {
transform: matrix3d(1, 0, 0, 0, 0, 1.243, 0, 0, 0, 0, 1, 0, 0, -119.441, 0, 1);
}
6.46% {
transform: matrix3d(1, 0, 0, 0, 0, 0.895, 0, 0, 0, 0, 1, 0, 0, -62.014, 0, 1);
}
8.11% {
transform: matrix3d(1, 0, 0, 0, 0, 0.817, 0, 0, 0, 0, 1, 0, 0, -31.647, 0, 1);
}
8.61% {
transform: matrix3d(1, 0, 0, 0, 0, 0.813, 0, 0, 0, 0, 1, 0, 0, -24.472, 0, 1);
}
12.11% {
transform: matrix3d(1, 0, 0, 0, 0, 0.9, 0, 0, 0, 0, 1, 0, 0, 5.53, 0, 1);
}
14.16% {
transform: matrix3d(1, 0, 0, 0, 0, 0.959, 0, 0, 0, 0, 1, 0, 0, 11.62, 0, 1);
}
16.12% {
transform: matrix3d(1, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, 0, 13.007, 0, 1);
}
19.72% {
transform: matrix3d(1, 0, 0, 0, 0, 1.012, 0, 0, 0, 0, 1, 0, 0, 10.247, 0, 1);
}
27.23% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 2.352, 0, 1);
}
30.83% {
transform: matrix3d(1, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0.585, 0, 1);
}
38.34% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.311, 0, 1);
}
41.99% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.244, 0, 1);
}
50% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.048, 0, 1);
}
60.56% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0.007, 0, 1);
}
82.78% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
100% {
transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
}
}
.ns-effect-slide-center.ns-hide {
animation-name: anim-slide-center;
animation-duration: 0.25s;
}
@keyframes anim-slide-center {
0% {
transform: translate3d(0, -30px, 0) translate3d(0, -100%, 0);
}
100% {
transform: translate3d(0, 0, 0);
}
}
.ns-effect-genie.ns-show,
.ns-effect-genie.ns-hide {
animation-name: anim-genie;
animation-duration: 0.4s;
}
@keyframes anim-genie {
0% {
opacity: 0;
transform: translate3d(0, calc(200% + 30px), 0) scale3d(0, 1, 1);
animation-timing-function: ease-in;
}
40% {
opacity: 0.5;
transform: translate3d(0, 0, 0) scale3d(0.02, 1.1, 1);
animation-timing-function: ease-out;
}
70% {
opacity: 0.6;
transform: translate3d(0, -40px, 0) scale3d(0.8, 1.1, 1);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
}

@ -0,0 +1,4 @@
.ns-box {
margin-left: auto;
text-align: right;
}

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

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

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² нотификация",
"welcome": "Добре дошли, стартирането беше успешно"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Notifikation",
"welcome": "Velkommen, modulet er succesfuldt startet!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Benachrichtigung",
"welcome": "Willkommen, Start war erfolgreich!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Notification",
"welcome": "Welcome, start was successful!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Notificaciones",
"welcome": "Bienvenido, ¡se iniciado correctamente!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Notification",
"welcome": "Bienvenue, le démarrage a été un succès!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² értesítés",
"welcome": "Üdvözöljük, indulás sikeres!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Notificatie",
"welcome": "Welkom, Succesvol gestart!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Уведомление",
"welcome": "Добро пожаловать, старт был успешным!"
}

@ -0,0 +1,4 @@
{
"sysTitle": "การแจงเตอน MagicMirror²",
"welcome": "ยนดอนรบ การเรมตนสำเรจแลว!"
}

@ -0,0 +1,6 @@
# Module: Calendar
The `calendar` module is one of the default modules of the MagicMirror².
This module displays events from a public .ical calendar. It can combine multiple calendars.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/calendar.html).

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

@ -0,0 +1,856 @@
/* global CalendarUtils, cloneObject */
/* MagicMirror²
* Module: Calendar
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
Module.register("calendar", {
// Define module defaults
defaults: {
maximumEntries: 10, // Total Maximum Entries
maximumNumberOfDays: 365,
limitDays: 0, // Limit the number of days shown, 0 = no limit
pastDaysCount: 0,
displaySymbol: true,
defaultSymbol: "calendar-alt", // Fontawesome Symbol see https://fontawesome.com/cheatsheet?from=io
defaultSymbolClassName: "fas fa-fw fa-",
showLocation: false,
displayRepeatingCountTitle: false,
defaultRepeatingCountTitle: "",
maxTitleLength: 25,
maxLocationTitleLength: 25,
wrapEvents: false, // Wrap events to multiple lines breaking at maxTitleLength
wrapLocationEvents: false,
maxTitleLines: 3,
maxEventTitleLines: 3,
fetchInterval: 60 * 60 * 1000, // Update every hour
animationSpeed: 2000,
fade: true,
urgency: 7,
timeFormat: "relative",
dateFormat: "MMM Do",
dateEndFormat: "LT",
fullDayEventDateFormat: "MMM Do",
showEnd: false,
getRelative: 6,
fadePoint: 0.25, // Start on 1/4th of the list.
hidePrivate: false,
hideOngoing: false,
hideTime: false,
showTimeToday: false,
colored: false,
customEvents: [], // Array of {keyword: "", symbol: "", color: ""} where Keyword is a regexp and symbol/color are to be applied for matched
tableClass: "small",
calendars: [
{
symbol: "calendar-alt",
url: "https://www.calendarlabs.com/templates/ical/US-Holidays.ics"
}
],
titleReplace: {
"De verjaardag van ": "",
"'s birthday": ""
},
locationTitleReplace: {
"street ": ""
},
broadcastEvents: true,
excludedEvents: [],
sliceMultiDayEvents: false,
broadcastPastEvents: false,
nextDaysRelative: false,
selfSignedCert: false,
coloredText: false,
coloredBorder: false,
coloredSymbol: false,
coloredBackground: false,
limitDaysNeverSkip: false,
flipDateHeaderTitle: false
},
requiresVersion: "2.1.0",
// Define required scripts.
getStyles: function () {
return ["calendar.css", "font-awesome.css"];
},
// Define required scripts.
getScripts: function () {
return ["calendarutils.js", "moment.js"];
},
// Define required translations.
getTranslations: function () {
// The translations for the default modules are defined in the core translation files.
// Therefore we can just return false. Otherwise we should have returned a dictionary.
// If you're trying to build your own module including translations, check out the documentation.
return false;
},
// Override start method.
start: function () {
const ONE_MINUTE = 60 * 1000;
Log.info(`Starting module: ${this.name}`);
if (this.config.colored) {
Log.warn("Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!");
this.config.coloredText = true;
this.config.coloredSymbol = true;
}
if (this.config.coloredSymbolOnly) {
Log.warn("Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!");
this.config.coloredText = false;
this.config.coloredSymbol = true;
}
// Set locale.
moment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat));
// clear data holder before start
this.calendarData = {};
// indicate no data available yet
this.loaded = false;
this.config.calendars.forEach((calendar) => {
calendar.url = calendar.url.replace("webcal://", "http://");
const calendarConfig = {
maximumEntries: calendar.maximumEntries,
maximumNumberOfDays: calendar.maximumNumberOfDays,
pastDaysCount: calendar.pastDaysCount,
broadcastPastEvents: calendar.broadcastPastEvents,
selfSignedCert: calendar.selfSignedCert
};
if (calendar.symbolClass === "undefined" || calendar.symbolClass === null) {
calendarConfig.symbolClass = "";
}
if (calendar.titleClass === "undefined" || calendar.titleClass === null) {
calendarConfig.titleClass = "";
}
if (calendar.timeClass === "undefined" || calendar.timeClass === null) {
calendarConfig.timeClass = "";
}
// we check user and password here for backwards compatibility with old configs
if (calendar.user && calendar.pass) {
Log.warn("Deprecation warning: Please update your calendar authentication configuration.");
Log.warn("https://github.com/MichMich/MagicMirror/tree/v2.1.2/modules/default/calendar#calendar-authentication-options");
calendar.auth = {
user: calendar.user,
pass: calendar.pass
};
}
// tell helper to start a fetcher for this calendar
// fetcher till cycle
this.addCalendar(calendar.url, calendar.auth, calendarConfig);
});
// Refresh the DOM every minute if needed: When using relative date format for events that start
// or end in less than an hour, the date shows minute granularity and we want to keep that accurate.
setTimeout(() => {
setInterval(() => {
this.updateDom(1);
}, ONE_MINUTE);
}, ONE_MINUTE - (new Date() % ONE_MINUTE));
},
// Override socket notification handler.
socketNotificationReceived: function (notification, payload) {
if (notification === "FETCH_CALENDAR") {
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
}
if (this.identifier !== payload.id) {
return;
}
if (notification === "CALENDAR_EVENTS") {
if (this.hasCalendarURL(payload.url)) {
this.calendarData[payload.url] = payload.events;
this.error = null;
this.loaded = true;
if (this.config.broadcastEvents) {
this.broadcastEvents();
}
}
} else if (notification === "CALENDAR_ERROR") {
let error_message = this.translate(payload.error_type);
this.error = this.translate("MODULE_CONFIG_ERROR", { MODULE_NAME: this.name, ERROR: error_message });
this.loaded = true;
}
this.updateDom(this.config.animationSpeed);
},
// Override dom generator.
getDom: function () {
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");
wrapper.className = this.config.tableClass;
if (this.error) {
wrapper.innerHTML = this.error;
wrapper.className = `${this.config.tableClass} dimmed`;
return wrapper;
}
if (events.length === 0) {
wrapper.innerHTML = this.loaded ? this.translate("EMPTY") : this.translate("LOADING");
wrapper.className = `${this.config.tableClass} dimmed`;
return wrapper;
}
let currentFadeStep = 0;
let startFade;
let fadeSteps;
if (this.config.fade && this.config.fadePoint < 1) {
if (this.config.fadePoint < 0) {
this.config.fadePoint = 0;
}
startFade = events.length * this.config.fadePoint;
fadeSteps = events.length - startFade;
}
let lastSeenDate = "";
events.forEach((event, index) => {
const dateAsString = moment(event.startDate, "x").format(this.config.dateFormat);
if (this.config.timeFormat === "dateheaders") {
if (lastSeenDate !== dateAsString) {
const dateRow = document.createElement("tr");
dateRow.className = "dateheader normal";
if (event.today) dateRow.className += " today";
else if (event.dayBeforeYesterday) dateRow.className += " dayBeforeYesterday";
else if (event.yesterday) dateRow.className += " yesterday";
else if (event.tomorrow) dateRow.className += " tomorrow";
else if (event.dayAfterTomorrow) dateRow.className += " dayAfterTomorrow";
const dateCell = document.createElement("td");
dateCell.colSpan = "3";
dateCell.innerHTML = dateAsString;
dateCell.style.paddingTop = "10px";
dateRow.appendChild(dateCell);
wrapper.appendChild(dateRow);
if (this.config.fade && index >= startFade) {
//fading
currentFadeStep = index - startFade;
dateRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;
}
lastSeenDate = dateAsString;
}
}
const eventWrapper = document.createElement("tr");
if (this.config.coloredText) {
eventWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`;
}
if (this.config.coloredBackground) {
eventWrapper.style.backgroundColor = this.colorForUrl(event.url, true);
}
if (this.config.coloredBorder) {
eventWrapper.style.borderColor = this.colorForUrl(event.url, false);
}
eventWrapper.className = "event-wrapper normal event";
if (event.today) eventWrapper.className += " today";
else if (event.dayBeforeYesterday) eventWrapper.className += " dayBeforeYesterday";
else if (event.yesterday) eventWrapper.className += " yesterday";
else if (event.tomorrow) eventWrapper.className += " tomorrow";
else if (event.dayAfterTomorrow) eventWrapper.className += " dayAfterTomorrow";
const symbolWrapper = document.createElement("td");
if (this.config.displaySymbol) {
if (this.config.coloredSymbol) {
symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`;
}
const symbolClass = this.symbolClassForUrl(event.url);
symbolWrapper.className = `symbol align-right ${symbolClass}`;
const symbols = this.symbolsForEvent(event);
symbols.forEach((s, index) => {
const symbol = document.createElement("span");
symbol.className = s;
if (index > 0) {
symbol.style.paddingLeft = "5px";
}
symbolWrapper.appendChild(symbol);
});
eventWrapper.appendChild(symbolWrapper);
} else if (this.config.timeFormat === "dateheaders") {
const blankCell = document.createElement("td");
blankCell.innerHTML = "&nbsp;&nbsp;&nbsp;";
eventWrapper.appendChild(blankCell);
}
const titleWrapper = document.createElement("td");
let repeatingCountTitle = "";
if (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) {
repeatingCountTitle = this.countTitleForUrl(event.url);
if (repeatingCountTitle !== "") {
const thisYear = new Date(parseInt(event.startDate)).getFullYear(),
yearDiff = thisYear - event.firstYear;
repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`;
}
}
// Color events if custom color is specified
if (this.config.customEvents.length > 0) {
for (let ev in this.config.customEvents) {
if (typeof this.config.customEvents[ev].color !== "undefined" && this.config.customEvents[ev].color !== "") {
let needle = new RegExp(this.config.customEvents[ev].keyword, "gi");
if (needle.test(event.title)) {
// Respect parameter ColoredSymbolOnly also for custom events
if (this.config.coloredText) {
eventWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;
titleWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;
}
if (this.config.displaySymbol && this.config.coloredSymbol) {
symbolWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;
}
break;
}
}
}
}
const transformedTitle = CalendarUtils.titleTransform(event.title, this.config.titleReplace);
titleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle;
const titleClass = this.titleClassForUrl(event.url);
if (!this.config.coloredText) {
titleWrapper.className = `title bright ${titleClass}`;
} else {
titleWrapper.className = `title ${titleClass}`;
}
if (this.config.timeFormat === "dateheaders") {
if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
if (event.fullDayEvent) {
titleWrapper.colSpan = "2";
titleWrapper.classList.add("align-left");
} else {
const timeWrapper = document.createElement("td");
timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`;
timeWrapper.style.paddingLeft = "2px";
timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left";
timeWrapper.innerHTML = moment(event.startDate, "x").format("LT");
// Add endDate to dataheaders if showEnd is enabled
if (this.config.showEnd) {
timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(moment(event.endDate, "x").format("LT"))}`;
}
eventWrapper.appendChild(timeWrapper);
if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right");
}
if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
} else {
const timeWrapper = document.createElement("td");
eventWrapper.appendChild(titleWrapper);
const now = new Date();
if (this.config.timeFormat === "absolute") {
// Use dateFormat
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.dateFormat));
// Add end time if showEnd
if (this.config.showEnd) {
timeWrapper.innerHTML += "-";
timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.dateEndFormat));
}
// For full day events we use the fullDayEventDateFormat
if (event.fullDayEvent) {
//subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day
event.endDate -= ONE_SECOND;
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat));
} else if (this.config.getRelative > 0 && event.startDate < now) {
// Ongoing and getRelative is set
timeWrapper.innerHTML = CalendarUtils.capFirst(
this.translate("RUNNING", {
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
timeUntilEnd: moment(event.endDate, "x").fromNow(true)
})
);
} else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * ONE_DAY) {
// Within urgency days
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow());
}
if (event.fullDayEvent && this.config.nextDaysRelative) {
// Full days events within the next two days
if (event.today) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
} else if (event.yesterday) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
} else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
} else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
}
}
}
} else {
// Show relative times
if (event.startDate >= now || (event.fullDayEvent && event.today)) {
// Use relative time
if (!this.config.hideTime && !event.fullDayEvent) {
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat }));
} else {
timeWrapper.innerHTML = CalendarUtils.capFirst(
moment(event.startDate, "x").calendar(null, {
sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
nextDay: `[${this.translate("TOMORROW")}]`,
nextWeek: "dddd",
sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat
})
);
}
if (event.fullDayEvent) {
// Full days events within the next two days
if (event.today) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
} else if (event.dayBeforeYesterday) {
if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY"));
}
} else if (event.yesterday) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
} else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
} else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
}
}
} 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 = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow());
}
} else {
// Ongoing event
timeWrapper.innerHTML = CalendarUtils.capFirst(
this.translate("RUNNING", {
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
timeUntilEnd: moment(event.endDate, "x").fromNow(true)
})
);
}
}
timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`;
eventWrapper.appendChild(timeWrapper);
}
// Create fade effect.
if (index >= startFade) {
currentFadeStep = index - startFade;
eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;
}
wrapper.appendChild(eventWrapper);
if (this.config.showLocation) {
if (event.location !== false) {
const locationRow = document.createElement("tr");
locationRow.className = "event-wrapper-location normal xsmall light";
if (event.today) locationRow.className += " today";
else if (event.dayBeforeYesterday) locationRow.className += " dayBeforeYesterday";
else if (event.yesterday) locationRow.className += " yesterday";
else if (event.tomorrow) locationRow.className += " tomorrow";
else if (event.dayAfterTomorrow) locationRow.className += " dayAfterTomorrow";
if (this.config.displaySymbol) {
const symbolCell = document.createElement("td");
locationRow.appendChild(symbolCell);
}
if (this.config.coloredText) {
locationRow.style.cssText = `color:${this.colorForUrl(event.url, false)}`;
}
if (this.config.coloredBackground) {
locationRow.style.backgroundColor = this.colorForUrl(event.url, true);
}
if (this.config.coloredBorder) {
locationRow.style.borderColor = this.colorForUrl(event.url, false);
}
const descCell = document.createElement("td");
descCell.className = "location";
descCell.colSpan = "2";
const transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace);
descCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines);
locationRow.appendChild(descCell);
wrapper.appendChild(locationRow);
if (index >= startFade) {
currentFadeStep = index - startFade;
locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;
}
}
}
});
return wrapper;
},
/**
* Checks if this config contains the calendar url.
* @param {string} url The calendar url
* @returns {boolean} True if the calendar config contains the url, False otherwise
*/
hasCalendarURL: function (url) {
for (const calendar of this.config.calendars) {
if (calendar.url === url) {
return true;
}
}
return false;
},
/**
* Creates the sorted list of all events.
* @param {boolean} limitNumberOfEntries Whether to filter returned events for display.
* @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();
let events = [];
for (const calendarUrl in this.calendarData) {
const calendar = this.calendarData[calendarUrl];
let remainingEntries = this.maximumEntriesForUrl(calendarUrl);
let maxPastDaysCompare = now - this.maximumPastDaysForUrl(calendarUrl) * ONE_DAY;
for (const e in calendar) {
const event = JSON.parse(JSON.stringify(calendar[e])); // clone object
if (this.config.hidePrivate && event.class === "PRIVATE") {
// do not add the current event, skip it
continue;
}
if (limitNumberOfEntries) {
if (event.endDate < maxPastDaysCompare) {
continue;
}
if (this.config.hideOngoing && event.startDate < now) {
continue;
}
if (this.listContainsEvent(events, event)) {
continue;
}
if (--remainingEntries < 0) {
break;
}
}
event.url = calendarUrl;
event.today = event.startDate >= today && event.startDate < today + ONE_DAY;
event.dayBeforeYesterday = event.startDate >= today - ONE_DAY * 2 && event.startDate < today - ONE_DAY;
event.yesterday = event.startDate >= today - ONE_DAY && event.startDate < today;
event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY;
event.dayAfterTomorrow = !event.tomorrow && event.startDate >= today + ONE_DAY * 2 && event.startDate < today + 3 * 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")) / 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 + 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);
event.startDate = midnight;
count += 1;
midnight = moment(midnight, "x").add(1, "day").format("x"); // next day
}
// 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) {
if (splitEvent.endDate > now && splitEvent.endDate <= future) {
events.push(splitEvent);
}
}
} else {
events.push(event);
}
}
}
events.sort(function (a, b) {
return a.startDate - b.startDate;
});
if (!limitNumberOfEntries) {
return events;
}
// Limit the number of days displayed
// If limitDays is set > 0, limit display to that number of days
if (this.config.limitDays > 0) {
let newEvents = [];
let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD");
let days = 0;
for (const ev of events) {
let eventDate = moment(ev.startDate, "x").format("YYYYMMDD");
// if date of event is later than lastdate
// check if we already are showing max unique days
if (eventDate > lastDate) {
// if the only entry in the first day is a full day event that day is not counted as unique
if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) {
days--;
}
days++;
if (days > this.config.limitDays) {
continue;
} else {
lastDate = eventDate;
}
}
newEvents.push(ev);
}
events = newEvents;
}
return events.slice(0, this.config.maximumEntries);
},
listContainsEvent: function (eventList, event) {
for (const evt of eventList) {
if (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate)) {
return true;
}
}
return false;
},
/**
* Requests node helper to add calendar url.
* @param {string} url The calendar url to add
* @param {object} auth The authentication method and credentials
* @param {object} calendarConfig The config of the specific calendar
*/
addCalendar: function (url, auth, calendarConfig) {
this.sendSocketNotification("ADD_CALENDAR", {
id: this.identifier,
url: url,
excludedEvents: calendarConfig.excludedEvents || this.config.excludedEvents,
maximumEntries: calendarConfig.maximumEntries || this.config.maximumEntries,
maximumNumberOfDays: calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays,
pastDaysCount: calendarConfig.pastDaysCount || this.config.pastDaysCount,
fetchInterval: calendarConfig.fetchInterval || this.config.fetchInterval,
symbolClass: calendarConfig.symbolClass,
titleClass: calendarConfig.titleClass,
timeClass: calendarConfig.timeClass,
auth: auth,
broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents,
selfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert
});
},
/**
* Retrieves the symbols for a specific event.
* @param {object} event Event to look for.
* @returns {string[]} The symbols
*/
symbolsForEvent: function (event) {
let symbols = this.getCalendarPropertyAsArray(event.url, "symbol", this.config.defaultSymbol);
if (event.recurringEvent === true && this.hasCalendarProperty(event.url, "recurringSymbol")) {
symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "recurringSymbol", this.config.defaultSymbol), symbols);
}
if (event.fullDayEvent === true && this.hasCalendarProperty(event.url, "fullDaySymbol")) {
symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "fullDaySymbol", this.config.defaultSymbol), symbols);
}
// If custom symbol is set, replace event symbol
for (let ev of this.config.customEvents) {
if (typeof ev.symbol !== "undefined" && ev.symbol !== "") {
let needle = new RegExp(ev.keyword, "gi");
if (needle.test(event.title)) {
// Get the default prefix for this class name and add to the custom symbol provided
const className = this.getCalendarProperty(event.url, "symbolClassName", this.config.defaultSymbolClassName);
symbols[0] = className + ev.symbol;
break;
}
}
}
return symbols;
},
mergeUnique: function (arr1, arr2) {
return arr1.concat(
arr2.filter(function (item) {
return arr1.indexOf(item) === -1;
})
);
},
/**
* Retrieves the symbolClass for a specific calendar url.
* @param {string} url The calendar url
* @returns {string} The class to be used for the symbols of the calendar
*/
symbolClassForUrl: function (url) {
return this.getCalendarProperty(url, "symbolClass", "");
},
/**
* Retrieves the titleClass for a specific calendar url.
* @param {string} url The calendar url
* @returns {string} The class to be used for the title of the calendar
*/
titleClassForUrl: function (url) {
return this.getCalendarProperty(url, "titleClass", "");
},
/**
* Retrieves the timeClass for a specific calendar url.
* @param {string} url The calendar url
* @returns {string} The class to be used for the time of the calendar
*/
timeClassForUrl: function (url) {
return this.getCalendarProperty(url, "timeClass", "");
},
/**
* Retrieves the calendar name for a specific calendar url.
* @param {string} url The calendar url
* @returns {string} The name of the calendar
*/
calendarNameForUrl: function (url) {
return this.getCalendarProperty(url, "name", "");
},
/**
* Retrieves the color for a specific calendar url.
* @param {string} url The calendar url
* @param {boolean} isBg Determines if we fetch the bgColor or not
* @returns {string} The color
*/
colorForUrl: function (url, isBg) {
return this.getCalendarProperty(url, isBg ? "bgColor" : "color", "#fff");
},
/**
* Retrieves the count title for a specific calendar url.
* @param {string} url The calendar url
* @returns {string} The title
*/
countTitleForUrl: function (url) {
return this.getCalendarProperty(url, "repeatingCountTitle", this.config.defaultRepeatingCountTitle);
},
/**
* Retrieves the maximum entry count for a specific calendar url.
* @param {string} url The calendar url
* @returns {number} The maximum entry count
*/
maximumEntriesForUrl: function (url) {
return this.getCalendarProperty(url, "maximumEntries", this.config.maximumEntries);
},
/**
* Retrieves the maximum count of past days which events of should be displayed for a specific calendar url.
* @param {string} url The calendar url
* @returns {number} The maximum past days count
*/
maximumPastDaysForUrl: function (url) {
return this.getCalendarProperty(url, "pastDaysCount", this.config.pastDaysCount);
},
/**
* Helper method to retrieve the property for a specific calendar url.
* @param {string} url The calendar url
* @param {string} property The property to look for
* @param {string} defaultValue The value if the property is not found
* @returns {*} The property
*/
getCalendarProperty: function (url, property, defaultValue) {
for (const calendar of this.config.calendars) {
if (calendar.url === url && calendar.hasOwnProperty(property)) {
return calendar[property];
}
}
return defaultValue;
},
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;
},
hasCalendarProperty: function (url, property) {
return !!this.getCalendarProperty(url, property, undefined);
},
/**
* Broadcasts the events to all other modules for reuse.
* The all events available in one array, sorted on startdate.
*/
broadcastEvents: function () {
const eventList = this.createEventList(false);
for (const event of eventList) {
event.symbol = this.symbolsForEvent(event);
event.calendarName = this.calendarNameForUrl(event.url);
event.color = this.colorForUrl(event.url, false);
delete event.url;
}
this.sendNotification("CALENDAR_EVENTS", eventList);
}
});

@ -0,0 +1,155 @@
/* MagicMirror²
* Node Helper: Calendar - CalendarFetcher
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
const https = require("https");
const digest = require("digest-fetch");
const ical = require("node-ical");
const fetch = require("fetch");
const Log = require("logger");
const NodeHelper = require("node_helper");
const CalendarFetcherUtils = require("./calendarfetcherutils");
/**
*
* @param {string} url The url of the calendar to fetch
* @param {number} reloadInterval Time in ms the calendar is fetched again
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
* @param {number} maximumEntries The maximum number of events fetched.
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
* @param {object} auth The object containing options for authentication against the calendar.
* @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
* @class
*/
const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
let reloadTimer = null;
let events = [];
let fetchFailedCallback = function () {};
let eventsReceivedCallback = function () {};
/**
* Initiates calendar fetch.
*/
const fetchCalendar = () => {
clearTimeout(reloadTimer);
reloadTimer = null;
const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
let fetcher = null;
let httpsAgent = null;
let headers = {
"User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`
};
if (selfSignedCert) {
httpsAgent = new https.Agent({
rejectUnauthorized: false
});
}
if (auth) {
if (auth.method === "bearer") {
headers.Authorization = `Bearer ${auth.pass}`;
} else if (auth.method === "digest") {
fetcher = new digest(auth.user, auth.pass).fetch(url, { headers: headers, agent: httpsAgent });
} else {
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
}
}
if (fetcher === null) {
fetcher = fetch(url, { headers: headers, agent: httpsAgent });
}
fetcher
.then(NodeHelper.checkFetchStatus)
.then((response) => response.text())
.then((responseData) => {
let data = [];
try {
data = ical.parseICS(responseData);
Log.debug(`parsed data=${JSON.stringify(data)}`);
events = CalendarFetcherUtils.filterEvents(data, {
excludedEvents,
includePastEvents,
maximumEntries,
maximumNumberOfDays
});
} catch (error) {
fetchFailedCallback(this, error);
scheduleTimer();
return;
}
this.broadcastEvents();
scheduleTimer();
})
.catch((error) => {
fetchFailedCallback(this, error);
scheduleTimer();
});
};
/**
* Schedule the timer for the next update.
*/
const scheduleTimer = function () {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchCalendar();
}, reloadInterval);
};
/* public methods */
/**
* Initiate fetchCalendar();
*/
this.startFetch = function () {
fetchCalendar();
};
/**
* Broadcast the existing events.
*/
this.broadcastEvents = function () {
Log.info(`Calendar-Fetcher: Broadcasting ${events.length} events.`);
eventsReceivedCallback(this);
};
/**
* Sets the on success callback
* @param {Function} callback The on success callback.
*/
this.onReceive = function (callback) {
eventsReceivedCallback = callback;
};
/**
* Sets the on error callback
* @param {Function} callback The on error callback.
*/
this.onError = function (callback) {
fetchFailedCallback = callback;
};
/**
* Returns the url of this fetcher.
* @returns {string} The url of this fetcher.
*/
this.url = function () {
return url;
};
/**
* Returns current available events for this fetcher.
* @returns {object[]} The current available events for this fetcher.
*/
this.events = function () {
return events;
};
};
module.exports = CalendarFetcher;

@ -0,0 +1,605 @@
/* MagicMirror²
* Calendar Fetcher Util Methods
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
/**
* @external Moment
*/
const path = require("path");
const moment = require("moment");
const zoneTable = require(path.join(__dirname, "windowsZones.json"));
const Log = require("../../../js/logger");
const CalendarFetcherUtils = {
/**
* Calculate the time correction, either dst/std or full day in cases where
* utc time is day before plus offset
* @param {object} event the event which needs adjustment
* @param {Date} date the date on which this event happens
* @returns {number} the necessary adjustment in hours
*/
calculateTimezoneAdjustment: function (event, date) {
let adjustHours = 0;
// if a timezone was specified
if (!event.start.tz) {
Log.debug(" if no tz, guess based on now");
event.start.tz = moment.tz.guess();
}
Log.debug(`initial tz=${event.start.tz}`);
// if there is a start date specified
if (event.start.tz) {
// if this is a windows timezone
if (event.start.tz.includes(" ")) {
// use the lookup table to get theIANA name as moment and date don't know MS timezones
let tz = CalendarFetcherUtils.getIanaTZFromMS(event.start.tz);
Log.debug(`corrected TZ=${tz}`);
// watch out for unregistered windows timezone names
// if we had a successful lookup
if (tz) {
// change the timezone to the IANA name
event.start.tz = tz;
// Log.debug("corrected timezone="+event.start.tz)
}
}
Log.debug(`corrected tz=${event.start.tz}`);
let current_offset = 0; // offset from TZ string or calculated
let mm = 0; // date with tz or offset
let start_offset = 0; // utc offset of created with tz
// if there is still an offset, lookup failed, use it
if (event.start.tz.startsWith("(")) {
const regex = /[+|-]\d*:\d*/;
const start_offsetString = event.start.tz.match(regex).toString().split(":");
let start_offset = parseInt(start_offsetString[0]);
start_offset *= event.start.tz[1] === "-" ? -1 : 1;
adjustHours = start_offset;
Log.debug(`defined offset=${start_offset} hours`);
current_offset = start_offset;
event.start.tz = "";
Log.debug(`ical offset=${current_offset} date=${date}`);
mm = moment(date);
let x = parseInt(moment(new Date()).utcOffset());
Log.debug(`net mins=${current_offset * 60 - x}`);
mm = mm.add(x - current_offset * 60, "minutes");
adjustHours = (current_offset * 60 - x) / 60;
event.start = mm.toDate();
Log.debug(`adjusted date=${event.start}`);
} else {
// get the start time in that timezone
let es = moment(event.start);
// check for start date prior to start of daylight changing date
if (es.format("YYYY") < 2007) {
es.set("year", 2013); // if so, use a closer date
}
Log.debug(`start date/time=${es.toDate()}`);
start_offset = moment.tz(es, event.start.tz).utcOffset();
Log.debug(`start offset=${start_offset}`);
Log.debug(`start date/time w tz =${moment.tz(moment(event.start), event.start.tz).toDate()}`);
// get the specified date in that timezone
mm = moment.tz(moment(date), event.start.tz);
Log.debug(`event date=${mm.toDate()}`);
current_offset = mm.utcOffset();
}
Log.debug(`event offset=${current_offset} hour=${mm.format("H")} event date=${mm.toDate()}`);
// if the offset is greater than 0, east of london
if (current_offset !== start_offset) {
// big offset
Log.debug("offset");
let h = parseInt(mm.format("H"));
// check if the event time is less than the offset
if (h > 0 && h < Math.abs(current_offset) / 60) {
// if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time)
// we need to fix that
//adjustHours = 24;
// Log.debug("adjusting date")
}
//-300 > -240
//if (Math.abs(current_offset) > Math.abs(start_offset)){
if (current_offset > start_offset) {
adjustHours -= 1;
Log.debug("adjust down 1 hour dst change");
//} else if (Math.abs(current_offset) < Math.abs(start_offset)) {
} else if (current_offset < start_offset) {
adjustHours += 1;
Log.debug("adjust up 1 hour dst change");
}
}
}
Log.debug(`adjustHours=${adjustHours}`);
return adjustHours;
},
/**
* Filter the events from ical according to the given config
* @param {object} data the calendar data from ical
* @param {object} config The configuration object
* @returns {string[]} the filtered events
*/
filterEvents: function (data, config) {
const newEvents = [];
// limitFunction doesn't do much limiting, see comment re: the dates
// array in rrule section below as to why we need to do the filtering
// ourselves
const limitFunction = function (date, i) {
return true;
};
const eventDate = function (event, time) {
return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time]));
};
Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
Object.entries(data).forEach(([key, event]) => {
Log.debug("Processing entry...");
const now = new Date();
const today = moment().startOf("day").toDate();
const future = moment().startOf("day").add(config.maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat.
let past = today;
if (config.includePastEvents) {
past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate();
}
// FIXME: Ugly fix to solve the facebook birthday issue.
// Otherwise, the recurring events only show the birthday for next year.
let isFacebookBirthday = false;
if (typeof event.uid !== "undefined") {
if (event.uid.indexOf("@facebook.com") !== -1) {
isFacebookBirthday = true;
}
}
if (event.type === "VEVENT") {
Log.debug(`Event:\n${JSON.stringify(event)}`);
let startDate = eventDate(event, "start");
let endDate;
if (typeof event.end !== "undefined") {
endDate = eventDate(event, "end");
} else if (typeof event.duration !== "undefined") {
endDate = startDate.clone().add(moment.duration(event.duration));
} else {
if (!isFacebookBirthday) {
// make copy of start date, separate storage area
endDate = moment(startDate.format("x"), "x");
} else {
endDate = moment(startDate).add(1, "days");
}
}
Log.debug(`start: ${startDate.toDate()}`);
Log.debug(`end:: ${endDate.toDate()}`);
// Calculate the duration of the event for use with recurring events.
let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x"));
Log.debug(`duration: ${duration}`);
// FIXME: Since the parsed json object from node-ical comes with time information
// this check could be removed (?)
if (event.start.length === 8) {
startDate = startDate.startOf("day");
}
const title = CalendarFetcherUtils.getTitleFromEvent(event);
Log.debug(`title: ${title}`);
let excluded = false,
dateFilter = null;
for (let f in config.excludedEvents) {
let filter = config.excludedEvents[f],
testTitle = title.toLowerCase(),
until = null,
useRegex = false,
regexFlags = "g";
if (filter instanceof Object) {
if (typeof filter.until !== "undefined") {
until = filter.until;
}
if (typeof filter.regex !== "undefined") {
useRegex = filter.regex;
}
// If additional advanced filtering is added in, this section
// must remain last as we overwrite the filter object with the
// filterBy string
if (filter.caseSensitive) {
filter = filter.filterBy;
testTitle = title;
} else if (useRegex) {
filter = filter.filterBy;
testTitle = title;
regexFlags += "i";
} else {
filter = filter.filterBy.toLowerCase();
}
} else {
filter = filter.toLowerCase();
}
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
if (until) {
dateFilter = until;
} else {
excluded = true;
}
break;
}
}
if (excluded) {
return;
}
const location = event.location || false;
const geo = event.geo || false;
const description = event.description || false;
if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) {
const rule = event.rrule;
let addedEvents = 0;
const pastMoment = moment(past);
const futureMoment = moment(future);
// can cause problems with e.g. birthdays before 1900
if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) {
rule.origOptions.dtstart.setYear(1900);
rule.options.dtstart.setYear(1900);
}
// For recurring events, get the set of start dates that fall within the range
// of dates we're looking for.
// kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time
let pastLocal = 0;
let futureLocal = 0;
if (CalendarFetcherUtils.isFullDayEvent(event)) {
Log.debug("fullday");
// if full day event, only use the date part of the ranges
pastLocal = pastMoment.toDate();
futureLocal = futureMoment.toDate();
Log.debug(`pastLocal: ${pastLocal}`);
Log.debug(`futureLocal: ${futureLocal}`);
} else {
// if we want past events
if (config.includePastEvents) {
// use the calculated past time for the between from
pastLocal = pastMoment.toDate();
} else {
// otherwise use NOW.. cause we shouldn't use any before now
pastLocal = moment().toDate(); //now
}
futureLocal = futureMoment.toDate(); // future
}
Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`);
const dates = rule.between(pastLocal, futureLocal, true, limitFunction);
Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`);
// The "dates" array contains the set of dates within our desired date range range that are valid
// for the recurrence rule. *However*, it's possible for us to have a specific recurrence that
// had its date changed from outside the range to inside the range. For the time being,
// we'll handle this by adding *all* recurrence entries into the set of dates that we check,
// because the logic below will filter out any recurrences that don't actually belong within
// our display range.
// Would be great if there was a better way to handle this.
Log.debug(`event.recurrences: ${event.recurrences}`);
if (event.recurrences !== undefined) {
for (let r in event.recurrences) {
// Only add dates that weren't already in the range we added from the rrule so that
// we don"t double-add those events.
if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) {
dates.push(new Date(r));
}
}
}
// Loop through the set of date entries to see which recurrences should be added to our event list.
for (let d in dates) {
let date = dates[d];
// Remove the time information of each date by using its substring, using the following method:
// .toISOString().substring(0,10).
// since the date is given as ISOString with YYYY-MM-DDTHH:MM:SS.SSSZ
// (see https://momentjs.com/docs/#/displaying/as-iso-string/).
const dateKey = date.toISOString().substring(0, 10);
let curEvent = event;
let showRecurrence = true;
// Get the offset of today where we are processing
// This will be the correction, we need to apply.
let nowOffset = new Date().getTimezoneOffset();
// For full day events, the time might be off from RRULE/Luxon problem
// Get time zone offset of the rule calculated event
let dateoffset = date.getTimezoneOffset();
// Reduce the time by the following offset.
Log.debug(` recurring date is ${date} offset is ${dateoffset}`);
let dh = moment(date).format("HH");
Log.debug(` recurring date is ${date} offset is ${dateoffset / 60} Hour is ${dh}`);
if (CalendarFetcherUtils.isFullDayEvent(event)) {
Log.debug("Fullday");
// If the offset is negative (east of GMT), where the problem is
if (dateoffset < 0) {
if (dh < Math.abs(dateoffset / 60)) {
// if the rrule byweekday WAS explicitly set , correct it
// reduce the time by the offset
if (curEvent.rrule.origOptions.byweekday !== undefined) {
// Apply the correction to the date/time to get it UTC relative
date = new Date(date.getTime() - Math.abs(24 * 60) * 60000);
}
// the duration was calculated way back at the top before we could correct the start time..
// fix it for this event entry
//duration = 24 * 60 * 60 * 1000;
Log.debug(`new recurring date1 fulldate is ${date}`);
}
} else {
// if the timezones are the same, correct date if needed
//if (event.start.tz === moment.tz.guess()) {
// if the date hour is less than the offset
if (24 - dh <= Math.abs(dateoffset / 60)) {
// if the rrule byweekday WAS explicitly set , correct it
if (curEvent.rrule.origOptions.byweekday !== undefined) {
// apply the correction to the date/time back to right day
date = new Date(date.getTime() + Math.abs(24 * 60) * 60000);
}
// the duration was calculated way back at the top before we could correct the start time..
// fix it for this event entry
//duration = 24 * 60 * 60 * 1000;
Log.debug(`new recurring date2 fulldate is ${date}`);
}
//}
}
} else {
// not full day, but luxon can still screw up the date on the rule processing
// we need to correct the date to get back to the right event for
if (dateoffset < 0) {
// if the date hour is less than the offset
if (dh <= Math.abs(dateoffset / 60)) {
// if the rrule byweekday WAS explicitly set , correct it
if (curEvent.rrule.origOptions.byweekday !== undefined) {
// Reduce the time by t:
// Apply the correction to the date/time to get it UTC relative
date = new Date(date.getTime() - Math.abs(24 * 60) * 60000);
}
// the duration was calculated way back at the top before we could correct the start time..
// fix it for this event entry
//duration = 24 * 60 * 60 * 1000;
Log.debug(`new recurring date1 is ${date}`);
}
} else {
// if the timezones are the same, correct date if needed
//if (event.start.tz === moment.tz.guess()) {
// if the date hour is less than the offset
if (24 - dh <= Math.abs(dateoffset / 60)) {
// if the rrule byweekday WAS explicitly set , correct it
if (curEvent.rrule.origOptions.byweekday !== undefined) {
// apply the correction to the date/time back to right day
date = new Date(date.getTime() + Math.abs(24 * 60) * 60000);
}
// the duration was calculated way back at the top before we could correct the start time..
// fix it for this event entry
//duration = 24 * 60 * 60 * 1000;
Log.debug(`new recurring date2 is ${date}`);
}
//}
}
}
startDate = moment(date);
Log.debug(`Corrected startDate: ${startDate.toDate()}`);
let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date);
// For each date that we're checking, it's possible that there is a recurrence override for that one day.
if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) {
// We found an override, so for this recurrence, use a potentially different title, start date, and duration.
curEvent = curEvent.recurrences[dateKey];
startDate = moment(curEvent.start);
duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x"));
}
// If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule.
else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) {
// This date is an exception date, which means we should skip it in the recurrence pattern.
showRecurrence = false;
}
Log.debug(`duration: ${duration}`);
endDate = moment(parseInt(startDate.format("x")) + duration, "x");
if (startDate.format("x") === endDate.format("x")) {
endDate = endDate.endOf("day");
}
const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent);
// If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add
// it to the event list.
if (endDate.isBefore(past) || startDate.isAfter(future)) {
showRecurrence = false;
}
if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) {
showRecurrence = false;
}
if (showRecurrence === true) {
Log.debug(`saving event: ${description}`);
addedEvents++;
newEvents.push({
title: recurrenceTitle,
startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"),
endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"),
fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event),
recurringEvent: true,
class: event.class,
firstYear: event.start.getFullYear(),
location: location,
geo: geo,
description: description
});
}
}
// End recurring event parsing.
} else {
// Single event.
const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
// Log.debug("full day event")
// if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00)
if (fullDayEvent && startDate.format("x") === endDate.format("x")) {
endDate = endDate.endOf("day");
}
if (config.includePastEvents) {
// Past event is too far in the past, so skip.
if (endDate < past) {
return;
}
} else {
// It's not a fullday event, and it is in the past, so skip.
if (!fullDayEvent && endDate < new Date()) {
return;
}
// It's a fullday event, and it is before today, So skip.
if (fullDayEvent && endDate <= today) {
return;
}
}
// It exceeds the maximumNumberOfDays limit, so skip.
if (startDate > future) {
return;
}
if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) {
return;
}
// get correction for date saving and dst change between now and then
let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startDate.toDate());
// Every thing is good. Add it to the list.
newEvents.push({
title: title,
startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"),
endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"),
fullDayEvent: fullDayEvent,
class: event.class,
location: location,
geo: geo,
description: description
});
}
}
});
newEvents.sort(function (a, b) {
return a.startDate - b.startDate;
});
return newEvents;
},
/**
* Lookup iana tz from windows
* @param {string} msTZName the timezone name to lookup
* @returns {string|null} the iana name or null of none is found
*/
getIanaTZFromMS: function (msTZName) {
// Get hash entry
const he = zoneTable[msTZName];
// If found return iana name, else null
return he ? he.iana[0] : null;
},
/**
* Gets the title from the event.
* @param {object} event The event object to check.
* @returns {string} The title of the event, or "Event" if no title is found.
*/
getTitleFromEvent: function (event) {
let title = "Event";
if (event.summary) {
title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary;
} else if (event.description) {
title = event.description;
}
return title;
},
/**
* Checks if an event is a fullday event.
* @param {object} event The event object to check.
* @returns {boolean} True if the event is a fullday event, false otherwise
*/
isFullDayEvent: function (event) {
if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") {
return true;
}
const start = event.start || 0;
const startDate = new Date(start);
const end = event.end || 0;
if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) {
// Is 24 hours, and starts on the middle of the night.
return true;
}
return false;
},
/**
* Determines if the user defined time filter should apply
* @param {Date} now Date object using previously created object for consistency
* @param {Moment} endDate Moment object representing the event end date
* @param {string} filter The time to subtract from the end date to determine if an event should be shown
* @returns {boolean} True if the event should be filtered out, false otherwise
*/
timeFilterApplies: function (now, endDate, filter) {
if (filter) {
const until = filter.split(" "),
value = parseInt(until[0]),
increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
filterUntil = moment(endDate.format()).subtract(value, increment);
return now < filterUntil.format("x");
}
return false;
},
/**
* Determines if the user defined title filter should apply
* @param {string} title the title of the event
* @param {string} filter the string to look for, can be a regex also
* @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string
* @param {string} regexFlags flags that should be applied to the regex
* @returns {boolean} True if the title should be filtered out, false otherwise
*/
titleFilterApplies: function (title, filter, useRegex, regexFlags) {
if (useRegex) {
let regexFilter = filter;
// Assume if leading slash, there is also trailing slash
if (filter[0] === "/") {
// Strip leading and trailing slashes
regexFilter = filter.substr(1).slice(0, -1);
}
return new RegExp(regexFilter, regexFlags).test(title);
} else {
return title.includes(filter);
}
}
};
if (typeof module !== "undefined") {
module.exports = CalendarFetcherUtils;
}

@ -0,0 +1,117 @@
/* MagicMirror²
* Calendar Util Methods
*
* By Rejas
* MIT Licensed.
*/
const CalendarUtils = {
/**
* Capitalize the first letter of a string
* @param {string} string The string to capitalize
* @returns {string} The capitalized string
*/
capFirst: function (string) {
return string.charAt(0).toUpperCase() + string.slice(1);
},
/**
* This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the
* corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input)
* it will a localeSpecification object with the system locale time format.
* @param {number} timeFormat Specifies either 12 or 24-hour time format
* @returns {moment.LocaleSpecification} formatted time
*/
getLocaleSpecification: function (timeFormat) {
switch (timeFormat) {
case 12: {
return { longDateFormat: { LT: "h:mm A" } };
}
case 24: {
return { longDateFormat: { LT: "HH:mm" } };
}
default: {
return { longDateFormat: { LT: moment.localeData().longDateFormat("LT") } };
}
}
},
/**
* Shortens a string if it's longer than maxLength and add an ellipsis to the end
* @param {string} string Text string to shorten
* @param {number} maxLength The max length of the string
* @param {boolean} wrapEvents Wrap the text after the line has reached maxLength
* @param {number} maxTitleLines The max number of vertical lines before cutting event title
* @returns {string} The shortened string
*/
shorten: function (string, maxLength, wrapEvents, maxTitleLines) {
if (typeof string !== "string") {
return "";
}
if (wrapEvents === true) {
const words = string.split(" ");
let temp = "";
let currentLine = "";
let line = 0;
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) {
// max - 1 to account for a space
currentLine += `${word} `;
} else {
line++;
if (line > maxTitleLines - 1) {
if (i < words.length) {
currentLine += "…";
}
break;
}
if (currentLine.length > 0) {
temp += `${currentLine}<br>${word} `;
} else {
temp += `${word}<br>`;
}
currentLine = "";
}
}
return (temp + currentLine).trim();
} else {
if (maxLength && typeof maxLength === "number" && string.length > maxLength) {
return `${string.trim().slice(0, maxLength)}`;
} else {
return string.trim();
}
}
},
/**
* Transforms the title of an event for usage.
* Replaces parts of the text as defined in config.titleReplace.
* Shortens title based on config.maxTitleLength and config.wrapEvents
* @param {string} title The title to transform.
* @param {object} titleReplace Pairs of strings to be replaced in the title
* @returns {string} The transformed title.
*/
titleTransform: function (title, titleReplace) {
let transformedTitle = title;
for (let needle in titleReplace) {
const replacement = titleReplace[needle];
const regParts = needle.match(/^\/(.+)\/([gim]*)$/);
if (regParts) {
// the parsed pattern is a regexp.
needle = new RegExp(regParts[1], regParts[2]);
}
transformedTitle = transformedTitle.replace(needle, replacement);
}
return transformedTitle;
}
};
if (typeof module !== "undefined") {
module.exports = CalendarUtils;
}

@ -0,0 +1,43 @@
/* CalendarFetcher Tester
* use this script with `node debug.js` to test the fetcher without the need
* of starting the MagicMirror² core. Adjust the values below to your desire.
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
// Alias modules mentioned in package.js under _moduleAliases.
require("module-alias/register");
const CalendarFetcher = require("./calendarfetcher");
const url = "https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics"; // Standard test URL
//const url = "https://www.googleapis.com/calendar/v3/calendars/primary/events/"; // URL for Bearer auth (must be configured in Google OAuth2 first)
const fetchInterval = 60 * 60 * 1000;
const maximumEntries = 10;
const maximumNumberOfDays = 365;
const user = "magicmirror";
const pass = "MyStrongPass";
const auth = {
user: user,
pass: pass
};
console.log("Create fetcher ...");
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
fetcher.onReceive(function (fetcher) {
console.log(fetcher.events());
console.log("------------------------------------------------------------");
process.exit(0);
});
fetcher.onError(function (fetcher, error) {
console.log("Fetcher error:");
console.log(error);
process.exit(1);
});
fetcher.startFetch();
console.log("Create fetcher done! ");

@ -0,0 +1,95 @@
/* MagicMirror²
* Node Helper: Calendar
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
const NodeHelper = require("node_helper");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher");
module.exports = NodeHelper.create({
// Override start method.
start: function () {
Log.log(`Starting node helper for: ${this.name}`);
this.fetchers = [];
},
// Override socketNotificationReceived method.
socketNotificationReceived: function (notification, payload) {
if (notification === "ADD_CALENDAR") {
this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id);
} else if (notification === "FETCH_CALENDAR") {
const key = payload.id + payload.url;
if (typeof this.fetchers[key] === "undefined") {
Log.error("Calendar Error. No fetcher exists with key: ", key);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
return;
}
this.fetchers[key].startFetch();
}
},
/**
* Creates a fetcher for a new url if it doesn't exist yet.
* Otherwise it reuses the existing one.
* @param {string} url The url of the calendar
* @param {number} fetchInterval How often does the calendar needs to be fetched in ms
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
* @param {number} maximumEntries The maximum number of events fetched.
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
* @param {object} auth The object containing options for authentication against the calendar.
* @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
* @param {string} identifier ID of the module
*/
createFetcher: function (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) {
try {
new URL(url);
} catch (error) {
Log.error("Calendar Error. Malformed calendar url: ", url, error);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
return;
}
let fetcher;
if (typeof this.fetchers[identifier + url] === "undefined") {
Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchInterval}`);
fetcher = new CalendarFetcher(url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert);
fetcher.onReceive((fetcher) => {
this.broadcastEvents(fetcher, identifier);
});
fetcher.onError((fetcher, error) => {
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error);
let error_type = NodeHelper.checkFetchError(error);
this.sendSocketNotification("CALENDAR_ERROR", {
id: identifier,
error_type
});
});
this.fetchers[identifier + url] = fetcher;
} else {
Log.log(`Use existing calendarfetcher for url: ${url}`);
fetcher = this.fetchers[identifier + url];
fetcher.broadcastEvents();
}
fetcher.startFetch();
},
/**
*
* @param {object} fetcher the fetcher associated with the calendar
* @param {string} identifier the identifier of the calendar
*/
broadcastEvents: function (fetcher, identifier) {
this.sendSocketNotification("CALENDAR_EVENTS", {
id: identifier,
url: fetcher.url(),
events: fetcher.events()
});
}
});

@ -0,0 +1,237 @@
{
"Dateline Standard Time": { "iana": ["Etc/GMT+12"] },
"UTC-11": { "iana": ["Etc/GMT+11"] },
"Aleutian Standard Time": { "iana": ["America/Adak"] },
"Hawaiian Standard Time": { "iana": ["Pacific/Honolulu"] },
"Marquesas Standard Time": { "iana": ["Pacific/Marquesas"] },
"Alaskan Standard Time": { "iana": ["America/Anchorage"] },
"UTC-09": { "iana": ["Etc/GMT+9"] },
"Pacific Standard Time (Mexico)": { "iana": ["America/Tijuana"] },
"UTC-08": { "iana": ["Etc/GMT+8"] },
"Pacific Standard Time": { "iana": ["America/Los_Angeles"] },
"US Mountain Standard Time": { "iana": ["America/Phoenix"] },
"Mountain Standard Time (Mexico)": { "iana": ["America/Chihuahua"] },
"Mountain Standard Time": { "iana": ["America/Denver"] },
"Central America Standard Time": { "iana": ["America/Guatemala"] },
"Central Standard Time": { "iana": ["America/Chicago"] },
"Easter Island Standard Time": { "iana": ["Pacific/Easter"] },
"Central Standard Time (Mexico)": { "iana": ["America/Mexico_City"] },
"Canada Central Standard Time": { "iana": ["America/Regina"] },
"SA Pacific Standard Time": { "iana": ["America/Bogota"] },
"Eastern Standard Time (Mexico)": { "iana": ["America/Cancun"] },
"Eastern Standard Time": { "iana": ["America/New_York"] },
"Haiti Standard Time": { "iana": ["America/Port-au-Prince"] },
"Cuba Standard Time": { "iana": ["America/Havana"] },
"US Eastern Standard Time": { "iana": ["America/Indianapolis"] },
"Turks And Caicos Standard Time": { "iana": ["America/Grand_Turk"] },
"Paraguay Standard Time": { "iana": ["America/Asuncion"] },
"Atlantic Standard Time": { "iana": ["America/Halifax"] },
"Venezuela Standard Time": { "iana": ["America/Caracas"] },
"Central Brazilian Standard Time": { "iana": ["America/Cuiaba"] },
"SA Western Standard Time": { "iana": ["America/La_Paz"] },
"Pacific SA Standard Time": { "iana": ["America/Santiago"] },
"Newfoundland Standard Time": { "iana": ["America/St_Johns"] },
"Tocantins Standard Time": { "iana": ["America/Araguaina"] },
"E. South America Standard Time": { "iana": ["America/Sao_Paulo"] },
"SA Eastern Standard Time": { "iana": ["America/Cayenne"] },
"Argentina Standard Time": { "iana": ["America/Buenos_Aires"] },
"Greenland Standard Time": { "iana": ["America/Godthab"] },
"Montevideo Standard Time": { "iana": ["America/Montevideo"] },
"Magallanes Standard Time": { "iana": ["America/Punta_Arenas"] },
"Saint Pierre Standard Time": { "iana": ["America/Miquelon"] },
"Bahia Standard Time": { "iana": ["America/Bahia"] },
"UTC-02": { "iana": ["Etc/GMT+2"] },
"Azores Standard Time": { "iana": ["Atlantic/Azores"] },
"Cape Verde Standard Time": { "iana": ["Atlantic/Cape_Verde"] },
"UTC": { "iana": ["Etc/GMT"] },
"GMT Standard Time": { "iana": ["Europe/London"] },
"Greenwich Standard Time": { "iana": ["Atlantic/Reykjavik"] },
"Sao Tome Standard Time": { "iana": ["Africa/Sao_Tome"] },
"Morocco Standard Time": { "iana": ["Africa/Casablanca"] },
"W. Europe Standard Time": { "iana": ["Europe/Berlin"] },
"Central Europe Standard Time": { "iana": ["Europe/Budapest"] },
"Romance Standard Time": { "iana": ["Europe/Paris"] },
"Central European Standard Time": { "iana": ["Europe/Warsaw"] },
"W. Central Africa Standard Time": { "iana": ["Africa/Lagos"] },
"Jordan Standard Time": { "iana": ["Asia/Amman"] },
"GTB Standard Time": { "iana": ["Europe/Bucharest"] },
"Middle East Standard Time": { "iana": ["Asia/Beirut"] },
"Egypt Standard Time": { "iana": ["Africa/Cairo"] },
"E. Europe Standard Time": { "iana": ["Europe/Chisinau"] },
"Syria Standard Time": { "iana": ["Asia/Damascus"] },
"West Bank Standard Time": { "iana": ["Asia/Hebron"] },
"South Africa Standard Time": { "iana": ["Africa/Johannesburg"] },
"FLE Standard Time": { "iana": ["Europe/Kiev"] },
"Israel Standard Time": { "iana": ["Asia/Jerusalem"] },
"Kaliningrad Standard Time": { "iana": ["Europe/Kaliningrad"] },
"Sudan Standard Time": { "iana": ["Africa/Khartoum"] },
"Libya Standard Time": { "iana": ["Africa/Tripoli"] },
"Namibia Standard Time": { "iana": ["Africa/Windhoek"] },
"Arabic Standard Time": { "iana": ["Asia/Baghdad"] },
"Turkey Standard Time": { "iana": ["Europe/Istanbul"] },
"Arab Standard Time": { "iana": ["Asia/Riyadh"] },
"Belarus Standard Time": { "iana": ["Europe/Minsk"] },
"Russian Standard Time": { "iana": ["Europe/Moscow"] },
"E. Africa Standard Time": { "iana": ["Africa/Nairobi"] },
"Iran Standard Time": { "iana": ["Asia/Tehran"] },
"Arabian Standard Time": { "iana": ["Asia/Dubai"] },
"Astrakhan Standard Time": { "iana": ["Europe/Astrakhan"] },
"Azerbaijan Standard Time": { "iana": ["Asia/Baku"] },
"Russia Time Zone 3": { "iana": ["Europe/Samara"] },
"Mauritius Standard Time": { "iana": ["Indian/Mauritius"] },
"Saratov Standard Time": { "iana": ["Europe/Saratov"] },
"Georgian Standard Time": { "iana": ["Asia/Tbilisi"] },
"Volgograd Standard Time": { "iana": ["Europe/Volgograd"] },
"Caucasus Standard Time": { "iana": ["Asia/Yerevan"] },
"Afghanistan Standard Time": { "iana": ["Asia/Kabul"] },
"West Asia Standard Time": { "iana": ["Asia/Tashkent"] },
"Ekaterinburg Standard Time": { "iana": ["Asia/Yekaterinburg"] },
"Pakistan Standard Time": { "iana": ["Asia/Karachi"] },
"Qyzylorda Standard Time": { "iana": ["Asia/Qyzylorda"] },
"India Standard Time": { "iana": ["Asia/Calcutta"] },
"Sri Lanka Standard Time": { "iana": ["Asia/Colombo"] },
"Nepal Standard Time": { "iana": ["Asia/Katmandu"] },
"Central Asia Standard Time": { "iana": ["Asia/Almaty"] },
"Bangladesh Standard Time": { "iana": ["Asia/Dhaka"] },
"Omsk Standard Time": { "iana": ["Asia/Omsk"] },
"Myanmar Standard Time": { "iana": ["Asia/Rangoon"] },
"SE Asia Standard Time": { "iana": ["Asia/Bangkok"] },
"Altai Standard Time": { "iana": ["Asia/Barnaul"] },
"W. Mongolia Standard Time": { "iana": ["Asia/Hovd"] },
"North Asia Standard Time": { "iana": ["Asia/Krasnoyarsk"] },
"N. Central Asia Standard Time": { "iana": ["Asia/Novosibirsk"] },
"Tomsk Standard Time": { "iana": ["Asia/Tomsk"] },
"China Standard Time": { "iana": ["Asia/Shanghai"] },
"North Asia East Standard Time": { "iana": ["Asia/Irkutsk"] },
"Singapore Standard Time": { "iana": ["Asia/Singapore"] },
"W. Australia Standard Time": { "iana": ["Australia/Perth"] },
"Taipei Standard Time": { "iana": ["Asia/Taipei"] },
"Ulaanbaatar Standard Time": { "iana": ["Asia/Ulaanbaatar"] },
"Aus Central W. Standard Time": { "iana": ["Australia/Eucla"] },
"Transbaikal Standard Time": { "iana": ["Asia/Chita"] },
"Tokyo Standard Time": { "iana": ["Asia/Tokyo"] },
"North Korea Standard Time": { "iana": ["Asia/Pyongyang"] },
"Korea Standard Time": { "iana": ["Asia/Seoul"] },
"Yakutsk Standard Time": { "iana": ["Asia/Yakutsk"] },
"Cen. Australia Standard Time": { "iana": ["Australia/Adelaide"] },
"AUS Central Standard Time": { "iana": ["Australia/Darwin"] },
"E. Australia Standard Time": { "iana": ["Australia/Brisbane"] },
"AUS Eastern Standard Time": { "iana": ["Australia/Sydney"] },
"West Pacific Standard Time": { "iana": ["Pacific/Port_Moresby"] },
"Tasmania Standard Time": { "iana": ["Australia/Hobart"] },
"Vladivostok Standard Time": { "iana": ["Asia/Vladivostok"] },
"Lord Howe Standard Time": { "iana": ["Australia/Lord_Howe"] },
"Bougainville Standard Time": { "iana": ["Pacific/Bougainville"] },
"Russia Time Zone 10": { "iana": ["Asia/Srednekolymsk"] },
"Magadan Standard Time": { "iana": ["Asia/Magadan"] },
"Norfolk Standard Time": { "iana": ["Pacific/Norfolk"] },
"Sakhalin Standard Time": { "iana": ["Asia/Sakhalin"] },
"Central Pacific Standard Time": { "iana": ["Pacific/Guadalcanal"] },
"Russia Time Zone 11": { "iana": ["Asia/Kamchatka"] },
"New Zealand Standard Time": { "iana": ["Pacific/Auckland"] },
"UTC+12": { "iana": ["Etc/GMT-12"] },
"Fiji Standard Time": { "iana": ["Pacific/Fiji"] },
"Chatham Islands Standard Time": { "iana": ["Pacific/Chatham"] },
"UTC+13": { "iana": ["Etc/GMT-13"] },
"Tonga Standard Time": { "iana": ["Pacific/Tongatapu"] },
"Samoa Standard Time": { "iana": ["Pacific/Apia"] },
"Line Islands Standard Time": { "iana": ["Pacific/Kiritimati"] },
"(UTC-12:00) International Date Line West": { "iana": ["Etc/GMT+12"] },
"(UTC-11:00) Midway Island, Samoa": { "iana": ["Pacific/Apia"] },
"(UTC-10:00) Hawaii": { "iana": ["Pacific/Honolulu"] },
"(UTC-09:00) Alaska": { "iana": ["America/Anchorage"] },
"(UTC-08:00) Pacific Time (US & Canada); Tijuana": { "iana": ["America/Los_Angeles"] },
"(UTC-08:00) Pacific Time (US and Canada); Tijuana": { "iana": ["America/Los_Angeles"] },
"(UTC-07:00) Mountain Time (US & Canada)": { "iana": ["America/Denver"] },
"(UTC-07:00) Mountain Time (US and Canada)": { "iana": ["America/Denver"] },
"(UTC-07:00) Chihuahua, La Paz, Mazatlan": { "iana": [null] },
"(UTC-07:00) Arizona": { "iana": ["America/Phoenix"] },
"(UTC-06:00) Central Time (US & Canada)": { "iana": ["America/Chicago"] },
"(UTC-06:00) Central Time (US and Canada)": { "iana": ["America/Chicago"] },
"(UTC-06:00) Saskatchewan": { "iana": ["America/Regina"] },
"(UTC-06:00) Guadalajara, Mexico City, Monterrey": { "iana": [null] },
"(UTC-06:00) Central America": { "iana": ["America/Guatemala"] },
"(UTC-05:00) Eastern Time (US & Canada)": { "iana": ["America/New_York"] },
"(UTC-05:00) Eastern Time (US and Canada)": { "iana": ["America/New_York"] },
"(UTC-05:00) Indiana (East)": { "iana": ["America/Indianapolis"] },
"(UTC-05:00) Bogota, Lima, Quito": { "iana": ["America/Bogota"] },
"(UTC-04:00) Atlantic Time (Canada)": { "iana": ["America/Halifax"] },
"(UTC-04:00) Georgetown, La Paz, San Juan": { "iana": ["America/La_Paz"] },
"(UTC-04:00) Santiago": { "iana": ["America/Santiago"] },
"(UTC-03:30) Newfoundland": { "iana": [null] },
"(UTC-03:00) Brasilia": { "iana": ["America/Sao_Paulo"] },
"(UTC-03:00) Georgetown": { "iana": ["America/Cayenne"] },
"(UTC-03:00) Greenland": { "iana": ["America/Godthab"] },
"(UTC-02:00) Mid-Atlantic": { "iana": [null] },
"(UTC-01:00) Azores": { "iana": ["Atlantic/Azores"] },
"(UTC-01:00) Cape Verde Islands": { "iana": ["Atlantic/Cape_Verde"] },
"(UTC) Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London": { "iana": [null] },
"(UTC) Monrovia, Reykjavik": { "iana": ["Atlantic/Reykjavik"] },
"(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague": { "iana": ["Europe/Budapest"] },
"(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb": { "iana": ["Europe/Warsaw"] },
"(UTC+01:00) Brussels, Copenhagen, Madrid, Paris": { "iana": ["Europe/Paris"] },
"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna": { "iana": ["Europe/Berlin"] },
"(UTC+01:00) West Central Africa": { "iana": ["Africa/Lagos"] },
"(UTC+02:00) Minsk": { "iana": ["Europe/Chisinau"] },
"(UTC+02:00) Cairo": { "iana": ["Africa/Cairo"] },
"(UTC+02:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius": { "iana": ["Europe/Kiev"] },
"(UTC+02:00) Athens, Bucharest, Istanbul": { "iana": ["Europe/Bucharest"] },
"(UTC+02:00) Jerusalem": { "iana": ["Asia/Jerusalem"] },
"(UTC+02:00) Harare, Pretoria": { "iana": ["Africa/Johannesburg"] },
"(UTC+03:00) Moscow, St. Petersburg, Volgograd": { "iana": ["Europe/Moscow"] },
"(UTC+03:00) Kuwait, Riyadh": { "iana": ["Asia/Riyadh"] },
"(UTC+03:00) Nairobi": { "iana": ["Africa/Nairobi"] },
"(UTC+03:00) Baghdad": { "iana": ["Asia/Baghdad"] },
"(UTC+03:30) Tehran": { "iana": ["Asia/Tehran"] },
"(UTC+04:00) Abu Dhabi, Muscat": { "iana": ["Asia/Dubai"] },
"(UTC+04:00) Baku, Tbilisi, Yerevan": { "iana": ["Asia/Yerevan"] },
"(UTC+04:30) Kabul": { "iana": [null] },
"(UTC+05:00) Ekaterinburg": { "iana": ["Asia/Yekaterinburg"] },
"(UTC+05:00) Tashkent": { "iana": ["Asia/Tashkent"] },
"(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi": { "iana": ["Asia/Calcutta"] },
"(UTC+05:45) Kathmandu": { "iana": ["Asia/Katmandu"] },
"(UTC+06:00) Astana, Dhaka": { "iana": ["Asia/Almaty"] },
"(UTC+06:00) Sri Jayawardenepura": { "iana": ["Asia/Colombo"] },
"(UTC+06:00) Almaty, Novosibirsk": { "iana": ["Asia/Novosibirsk"] },
"(UTC+06:30) Yangon (Rangoon)": { "iana": ["Asia/Rangoon"] },
"(UTC+07:00) Bangkok, Hanoi, Jakarta": { "iana": ["Asia/Bangkok"] },
"(UTC+07:00) Krasnoyarsk": { "iana": ["Asia/Krasnoyarsk"] },
"(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi": { "iana": ["Asia/Shanghai"] },
"(UTC+08:00) Kuala Lumpur, Singapore": { "iana": ["Asia/Singapore"] },
"(UTC+08:00) Taipei": { "iana": ["Asia/Taipei"] },
"(UTC+08:00) Perth": { "iana": ["Australia/Perth"] },
"(UTC+08:00) Irkutsk, Ulaanbaatar": { "iana": ["Asia/Irkutsk"] },
"(UTC+09:00) Seoul": { "iana": ["Asia/Seoul"] },
"(UTC+09:00) Osaka, Sapporo, Tokyo": { "iana": ["Asia/Tokyo"] },
"(UTC+09:00) Yakutsk": { "iana": ["Asia/Yakutsk"] },
"(UTC+09:30) Darwin": { "iana": ["Australia/Darwin"] },
"(UTC+09:30) Adelaide": { "iana": ["Australia/Adelaide"] },
"(UTC+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] },
"(GMT+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] },
"(UTC+10:00) Brisbane": { "iana": ["Australia/Brisbane"] },
"(UTC+10:00) Hobart": { "iana": ["Australia/Hobart"] },
"(UTC+10:00) Vladivostok": { "iana": ["Asia/Vladivostok"] },
"(UTC+10:00) Guam, Port Moresby": { "iana": ["Pacific/Port_Moresby"] },
"(UTC+11:00) Magadan, Solomon Islands, New Caledonia": { "iana": ["Pacific/Guadalcanal"] },
"(UTC+12:00) Fiji, Kamchatka, Marshall Is.": { "iana": [null] },
"(UTC+12:00) Auckland, Wellington": { "iana": ["Pacific/Auckland"] },
"(UTC+13:00) Nuku'alofa": { "iana": ["Pacific/Tongatapu"] },
"(UTC-03:00) Buenos Aires": { "iana": ["America/Buenos_Aires"] },
"(UTC+02:00) Beirut": { "iana": ["Asia/Beirut"] },
"(UTC+02:00) Amman": { "iana": ["Asia/Amman"] },
"(UTC-06:00) Guadalajara, Mexico City, Monterrey - New": { "iana": ["America/Mexico_City"] },
"(UTC-07:00) Chihuahua, La Paz, Mazatlan - New": { "iana": ["America/Chihuahua"] },
"(UTC-08:00) Tijuana, Baja California": { "iana": ["America/Tijuana"] },
"(UTC+02:00) Windhoek": { "iana": ["Africa/Windhoek"] },
"(UTC+03:00) Tbilisi": { "iana": ["Asia/Tbilisi"] },
"(UTC-04:00) Manaus": { "iana": ["America/Cuiaba"] },
"(UTC-03:00) Montevideo": { "iana": ["America/Montevideo"] },
"(UTC+04:00) Yerevan": { "iana": [null] },
"(UTC-04:30) Caracas": { "iana": ["America/Caracas"] },
"(UTC) Casablanca": { "iana": ["Africa/Casablanca"] },
"(UTC+05:00) Islamabad, Karachi": { "iana": ["Asia/Karachi"] },
"(UTC+04:00) Port Louis": { "iana": ["Indian/Mauritius"] },
"(UTC) Coordinated Universal Time": { "iana": ["Etc/GMT"] },
"(UTC-04:00) Asuncion": { "iana": ["America/Asuncion"] },
"(UTC+12:00) Petropavlovsk-Kamchatsky": { "iana": [null] }
}

@ -0,0 +1,6 @@
# Module: Clock
The `clock` module is one of the default modules of the MagicMirror².
This module displays the current date and time. The information will be updated realtime.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/clock.html).

@ -0,0 +1,306 @@
/* global SunCalc, formatTime */
/* MagicMirror²
* Module: Clock
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
Module.register("clock", {
// Module config defaults.
defaults: {
displayType: "digital", // options: digital, analog, both
timeFormat: config.timeFormat,
timezone: null,
displaySeconds: true,
showPeriod: true,
showPeriodUpper: false,
clockBold: false,
showDate: true,
showTime: true,
showWeek: false,
dateFormat: "dddd, LL",
sendNotifications: false,
/* specific to the analog clock */
analogSize: "200px",
analogFace: "simple", // options: 'none', 'simple', 'face-###' (where ### is 001 to 012 inclusive)
analogPlacement: "bottom", // options: 'top', 'bottom', 'left', 'right'
analogShowDate: "top", // OBSOLETE, can be replaced with analogPlacement and showTime, options: false, 'top', or 'bottom'
secondsColor: "#888888",
showSunTimes: false,
showMoonTimes: false,
lat: 47.630539,
lon: -122.344147
},
// Define required scripts.
getScripts: function () {
return ["moment.js", "moment-timezone.js", "suncalc.js"];
},
// Define styles.
getStyles: function () {
return ["clock_styles.css"];
},
// Define start sequence.
start: function () {
Log.info(`Starting module: ${this.name}`);
// Schedule update interval.
this.second = moment().second();
this.minute = moment().minute();
// Calculate how many ms should pass until next update depending on if seconds is displayed or not
const delayCalculator = (reducedSeconds) => {
const EXTRA_DELAY = 50; // Deliberate imperceptible delay to prevent off-by-one timekeeping errors
if (this.config.displaySeconds) {
return 1000 - moment().milliseconds() + EXTRA_DELAY;
} else {
return (60 - reducedSeconds) * 1000 - moment().milliseconds() + EXTRA_DELAY;
}
};
// A recursive timeout function instead of interval to avoid drifting
const notificationTimer = () => {
this.updateDom();
if (this.config.sendNotifications) {
// If seconds is displayed CLOCK_SECOND-notification should be sent (but not when CLOCK_MINUTE-notification is sent)
if (this.config.displaySeconds) {
this.second = moment().second();
if (this.second !== 0) {
this.sendNotification("CLOCK_SECOND", this.second);
setTimeout(notificationTimer, delayCalculator(0));
return;
}
}
// If minute changed or seconds isn't displayed send CLOCK_MINUTE-notification
this.minute = moment().minute();
this.sendNotification("CLOCK_MINUTE", this.minute);
}
setTimeout(notificationTimer, delayCalculator(0));
};
// Set the initial timeout with the amount of seconds elapsed as
// reducedSeconds, so it will trigger when the minute changes
setTimeout(notificationTimer, delayCalculator(this.second));
// Set locale.
moment.locale(config.language);
},
// Override dom generator.
getDom: function () {
const wrapper = document.createElement("div");
wrapper.classList.add("clock-grid");
/************************************
* Create wrappers for analog and digital clock
*/
const analogWrapper = document.createElement("div");
analogWrapper.className = "clock-circle";
const digitalWrapper = document.createElement("div");
digitalWrapper.className = "digital";
digitalWrapper.style.gridArea = "center";
/************************************
* Create wrappers for DIGITAL clock
*/
const dateWrapper = document.createElement("div");
const timeWrapper = document.createElement("div");
const secondsWrapper = document.createElement("sup");
const periodWrapper = document.createElement("span");
const sunWrapper = document.createElement("div");
const moonWrapper = document.createElement("div");
const weekWrapper = document.createElement("div");
// Style Wrappers
dateWrapper.className = "date normal medium";
timeWrapper.className = "time bright large light";
secondsWrapper.className = "seconds dimmed";
sunWrapper.className = "sun dimmed small";
moonWrapper.className = "moon dimmed small";
weekWrapper.className = "week dimmed medium";
// Set content of wrappers.
// The moment().format("h") method has a bug on the Raspberry Pi.
// So we need to generate the timestring manually.
// See issue: https://github.com/MichMich/MagicMirror/issues/181
let timeString;
const now = moment();
if (this.config.timezone) {
now.tz(this.config.timezone);
}
let hourSymbol = "HH";
if (this.config.timeFormat !== 24) {
hourSymbol = "h";
}
if (this.config.clockBold) {
timeString = now.format(`${hourSymbol}[<span class="bold">]mm[</span>]`);
} else {
timeString = now.format(`${hourSymbol}:mm`);
}
if (this.config.showDate) {
dateWrapper.innerHTML = now.format(this.config.dateFormat);
digitalWrapper.appendChild(dateWrapper);
}
if (this.config.displayType !== "analog" && this.config.showTime) {
timeWrapper.innerHTML = timeString;
secondsWrapper.innerHTML = now.format("ss");
if (this.config.showPeriodUpper) {
periodWrapper.innerHTML = now.format("A");
} else {
periodWrapper.innerHTML = now.format("a");
}
if (this.config.displaySeconds) {
timeWrapper.appendChild(secondsWrapper);
}
if (this.config.showPeriod && this.config.timeFormat !== 24) {
timeWrapper.appendChild(periodWrapper);
}
digitalWrapper.appendChild(timeWrapper);
}
/****************************************************************
* Create wrappers for Sun Times, only if specified in config
*/
if (this.config.showSunTimes) {
const sunTimes = SunCalc.getTimes(now, this.config.lat, this.config.lon);
const isVisible = now.isBetween(sunTimes.sunrise, sunTimes.sunset);
let nextEvent;
if (now.isBefore(sunTimes.sunrise)) {
nextEvent = sunTimes.sunrise;
} else if (now.isBefore(sunTimes.sunset)) {
nextEvent = sunTimes.sunset;
} else {
const tomorrowSunTimes = SunCalc.getTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon);
nextEvent = tomorrowSunTimes.sunrise;
}
const untilNextEvent = moment.duration(moment(nextEvent).diff(now));
const untilNextEventString = `${untilNextEvent.hours()}h ${untilNextEvent.minutes()}m`;
sunWrapper.innerHTML =
`<span class="${isVisible ? "bright" : ""}"><i class="fas fa-sun" aria-hidden="true"></i> ${untilNextEventString}</span>` +
`<span><i class="fas fa-arrow-up" aria-hidden="true"></i> ${formatTime(this.config, sunTimes.sunrise)}</span>` +
`<span><i class="fas fa-arrow-down" aria-hidden="true"></i> ${formatTime(this.config, sunTimes.sunset)}</span>`;
digitalWrapper.appendChild(sunWrapper);
}
/****************************************************************
* Create wrappers for Moon Times, only if specified in config
*/
if (this.config.showMoonTimes) {
const moonIllumination = SunCalc.getMoonIllumination(now.toDate());
const moonTimes = SunCalc.getMoonTimes(now, this.config.lat, this.config.lon);
const moonRise = moonTimes.rise;
let moonSet;
if (moment(moonTimes.set).isAfter(moonTimes.rise)) {
moonSet = moonTimes.set;
} else {
const nextMoonTimes = SunCalc.getMoonTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon);
moonSet = nextMoonTimes.set;
}
const isVisible = now.isBetween(moonRise, moonSet) || moonTimes.alwaysUp === true;
const illuminatedFractionString = `${Math.round(moonIllumination.fraction * 100)}%`;
moonWrapper.innerHTML =
`<span class="${isVisible ? "bright" : ""}"><i class="fas fa-moon" aria-hidden="true"></i> ${illuminatedFractionString}</span>` +
`<span><i class="fas fa-arrow-up" aria-hidden="true"></i> ${moonRise ? formatTime(this.config, moonRise) : "..."}</span>` +
`<span><i class="fas fa-arrow-down" aria-hidden="true"></i> ${moonSet ? formatTime(this.config, moonSet) : "..."}</span>`;
digitalWrapper.appendChild(moonWrapper);
}
if (this.config.showWeek) {
weekWrapper.innerHTML = this.translate("WEEK", { weekNumber: now.week() });
digitalWrapper.appendChild(weekWrapper);
}
/****************************************************************
* Create wrappers for ANALOG clock, only if specified in config
*/
if (this.config.displayType !== "digital") {
// If it isn't 'digital', then an 'analog' clock was also requested
// Calculate the degree offset for each hand of the clock
if (this.config.timezone) {
now.tz(this.config.timezone);
}
const second = now.seconds() * 6,
minute = now.minute() * 6 + second / 60,
hour = ((now.hours() % 12) / 12) * 360 + 90 + minute / 12;
// Create wrappers
analogWrapper.style.width = this.config.analogSize;
analogWrapper.style.height = this.config.analogSize;
if (this.config.analogFace !== "" && this.config.analogFace !== "simple" && this.config.analogFace !== "none") {
analogWrapper.style.background = `url(${this.data.path}faces/${this.config.analogFace}.svg)`;
analogWrapper.style.backgroundSize = "100%";
// The following line solves issue: https://github.com/MichMich/MagicMirror/issues/611
// analogWrapper.style.border = "1px solid black";
analogWrapper.style.border = "rgba(0, 0, 0, 0.1)"; //Updated fix for Issue 611 where non-black backgrounds are used
} else if (this.config.analogFace !== "none") {
analogWrapper.style.border = "2px solid white";
}
const clockFace = document.createElement("div");
clockFace.className = "clock-face";
const clockHour = document.createElement("div");
clockHour.id = "clock-hour";
clockHour.style.transform = `rotate(${hour}deg)`;
clockHour.className = "clock-hour";
const clockMinute = document.createElement("div");
clockMinute.id = "clock-minute";
clockMinute.style.transform = `rotate(${minute}deg)`;
clockMinute.className = "clock-minute";
// Combine analog wrappers
clockFace.appendChild(clockHour);
clockFace.appendChild(clockMinute);
if (this.config.displaySeconds) {
const clockSecond = document.createElement("div");
clockSecond.id = "clock-second";
clockSecond.style.transform = `rotate(${second}deg)`;
clockSecond.className = "clock-second";
clockSecond.style.backgroundColor = this.config.secondsColor;
clockFace.appendChild(clockSecond);
}
analogWrapper.appendChild(clockFace);
}
/*******************************************
* Update placement, respect old analogShowDate even if it's not needed anymore
*/
if (this.config.displayType === "analog") {
// Display only an analog clock
if (this.config.showDate) {
// Add date to the analog clock
dateWrapper.innerHTML = now.format(this.config.dateFormat);
wrapper.appendChild(dateWrapper);
}
if (this.config.analogShowDate === "bottom") {
wrapper.classList.add("clock-grid-bottom");
} else if (this.config.analogShowDate === "top") {
wrapper.classList.add("clock-grid-top");
}
wrapper.appendChild(analogWrapper);
} else if (this.config.displayType === "digital") {
wrapper.appendChild(digitalWrapper);
} else if (this.config.displayType === "both") {
wrapper.classList.add(`clock-grid-${this.config.analogPlacement}`);
wrapper.appendChild(analogWrapper);
wrapper.appendChild(digitalWrapper);
}
// Return the wrapper to the dom.
return wrapper;
}
});

@ -0,0 +1,93 @@
.clock-grid {
display: inline-flex;
gap: 15px;
}
.clock-grid-left {
flex-direction: row;
}
.clock-grid-right {
flex-direction: row-reverse;
}
.clock-grid-top {
flex-direction: column;
}
.clock-grid-bottom {
flex-direction: column-reverse;
}
.clock-circle {
place-self: center;
position: relative;
border-radius: 50%;
background-size: 100%;
}
.clock-face {
width: 100%;
height: 100%;
}
.clock-face::after {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
background: var(--color-text-bright);
border-radius: 3px;
content: "";
display: block;
}
.clock-hour {
width: 0;
height: 0;
position: absolute;
top: 50%;
left: 50%;
margin: -2px 0 -2px -25%; /* numbers must match negative length & thickness */
padding: 2px 0 2px 25%; /* indicator length & thickness */
background: var(--color-text-bright);
transform-origin: 100% 50%;
border-radius: 3px 0 0 3px;
}
.clock-minute {
width: 0;
height: 0;
position: absolute;
top: 50%;
left: 50%;
margin: -35% -2px 0; /* numbers must match negative length & thickness */
padding: 35% 2px 0; /* indicator length & thickness */
background: var(--color-text-bright);
transform-origin: 50% 100%;
border-radius: 3px 0 0 3px;
}
.clock-second {
width: 0;
height: 0;
position: absolute;
top: 50%;
left: 50%;
margin: -38% -1px 0 0; /* numbers must match negative length & thickness */
padding: 38% 1px 0 0; /* indicator length & thickness */
background: var(--color-text);
transform-origin: 50% 100%;
}
.module.clock .sun,
.module.clock .moon {
display: flex;
}
.module.clock .sun > *,
.module.clock .moon > * {
flex: 1;
}

@ -0,0 +1 @@
<svg id="Hour_Markers_-_Singlets" data-name="Hour Markers - Singlets" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250"><defs><style>.cls-1,.cls-2{fill:none;stroke:#fff;stroke-linecap:round;stroke-miterlimit:10;}.cls-2{stroke-width:0.5px;}</style></defs><title>face-001</title><line class="cls-1" x1="125" y1="1.25" x2="125" y2="16.23"/><line class="cls-1" x1="186.87" y1="17.83" x2="179.39" y2="30.8"/><line class="cls-1" x1="232.17" y1="63.12" x2="219.2" y2="70.61"/><line class="cls-1" x1="248.75" y1="125" x2="233.77" y2="125"/><line class="cls-1" x1="232.17" y1="186.87" x2="219.2" y2="179.39"/><line class="cls-1" x1="186.88" y1="232.17" x2="179.39" y2="219.2"/><line class="cls-1" x1="125" y1="248.75" x2="125" y2="233.77"/><line class="cls-1" x1="63.13" y1="232.17" x2="70.61" y2="219.2"/><line class="cls-1" x1="17.83" y1="186.88" x2="30.8" y2="179.39"/><line class="cls-1" x1="1.25" y1="125" x2="16.23" y2="125"/><line class="cls-1" x1="17.83" y1="63.13" x2="30.8" y2="70.61"/><line class="cls-1" x1="63.12" y1="17.83" x2="70.61" y2="30.8"/><line class="cls-2" x1="138.01" y1="1.25" x2="136.96" y2="11.23"/><line class="cls-2" x1="150.87" y1="3.29" x2="148.78" y2="13.11"/><line class="cls-2" x1="163.45" y1="6.66" x2="160.35" y2="16.21"/><line class="cls-2" x1="175.61" y1="11.33" x2="171.53" y2="20.5"/><line class="cls-2" x1="198.14" y1="24.33" x2="192.24" y2="32.45"/><line class="cls-2" x1="208.26" y1="32.53" x2="201.54" y2="39.99"/><line class="cls-2" x1="217.47" y1="41.74" x2="210.01" y2="48.46"/><line class="cls-2" x1="225.67" y1="51.86" x2="217.55" y2="57.76"/><line class="cls-2" x1="238.67" y1="74.39" x2="229.5" y2="78.47"/><line class="cls-2" x1="243.34" y1="86.55" x2="233.79" y2="89.65"/><line class="cls-2" x1="246.71" y1="99.13" x2="236.89" y2="101.22"/><line class="cls-2" x1="248.75" y1="111.99" x2="238.77" y2="113.04"/><line class="cls-2" x1="248.75" y1="138.01" x2="238.77" y2="136.96"/><line class="cls-2" x1="246.71" y1="150.87" x2="236.89" y2="148.78"/><line class="cls-2" x1="243.34" y1="163.45" x2="233.79" y2="160.35"/><line class="cls-2" x1="238.67" y1="175.61" x2="229.5" y2="171.53"/><line class="cls-2" x1="225.67" y1="198.14" x2="217.55" y2="192.24"/><line class="cls-2" x1="217.47" y1="208.26" x2="210.01" y2="201.54"/><line class="cls-2" x1="208.26" y1="217.47" x2="201.54" y2="210.01"/><line class="cls-2" x1="198.14" y1="225.67" x2="192.24" y2="217.55"/><line class="cls-2" x1="175.61" y1="238.67" x2="171.53" y2="229.5"/><line class="cls-2" x1="163.45" y1="243.34" x2="160.35" y2="233.79"/><line class="cls-2" x1="150.87" y1="246.71" x2="148.78" y2="236.89"/><line class="cls-2" x1="138.01" y1="248.75" x2="136.96" y2="238.77"/><line class="cls-2" x1="111.99" y1="248.75" x2="113.04" y2="238.77"/><line class="cls-2" x1="99.13" y1="246.71" x2="101.22" y2="236.89"/><line class="cls-2" x1="86.55" y1="243.34" x2="89.65" y2="233.79"/><line class="cls-2" x1="74.39" y1="238.67" x2="78.47" y2="229.5"/><line class="cls-2" x1="51.86" y1="225.67" x2="57.76" y2="217.55"/><line class="cls-2" x1="41.74" y1="217.47" x2="48.46" y2="210.01"/><line class="cls-2" x1="32.53" y1="208.26" x2="39.99" y2="201.54"/><line class="cls-2" x1="24.33" y1="198.14" x2="32.45" y2="192.24"/><line class="cls-2" x1="11.33" y1="175.61" x2="20.5" y2="171.53"/><line class="cls-2" x1="6.66" y1="163.45" x2="16.21" y2="160.35"/><line class="cls-2" x1="3.29" y1="150.87" x2="13.11" y2="148.78"/><line class="cls-2" x1="1.25" y1="138.01" x2="11.23" y2="136.96"/><line class="cls-2" x1="1.25" y1="111.99" x2="11.23" y2="113.04"/><line class="cls-2" x1="3.29" y1="99.13" x2="13.11" y2="101.22"/><line class="cls-2" x1="6.66" y1="86.55" x2="16.21" y2="89.65"/><line class="cls-2" x1="11.33" y1="74.39" x2="20.5" y2="78.47"/><line class="cls-2" x1="24.33" y1="51.86" x2="32.45" y2="57.76"/><line class="cls-2" x1="32.53" y1="41.74" x2="39.99" y2="48.46"/><line class="cls-2" x1="41.74" y1="32.53" x2="48.46" y2="39.99"/><line class="cls-2" x1="51.86" y1="24.33" x2="57.76" y2="32.45"/><line class="cls-2" x1="74.39" y1="11.33" x2="78.47" y2="20.5"/><line class="cls-2" x1="86.55" y1="6.66" x2="89.65" y2="16.21"/><line class="cls-2" x1="99.13" y1="3.29" x2="101.22" y2="13.11"/><line class="cls-2" x1="111.99" y1="1.25" x2="113.04" y2="11.23"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@ -0,0 +1 @@
<svg id="Hour_Markers_-_Doubles" data-name="Hour Markers - Doubles" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250"><defs><style>.cls-1{fill:none;stroke:#fff;stroke-linecap:round;stroke-miterlimit:10;stroke-width:2.98px;}</style></defs><title>face-002</title><line class="cls-1" x1="122.01" y1="1.75" x2="122.01" y2="16.67"/><line class="cls-1" x1="186.62" y1="18.26" x2="179.17" y2="31.18"/><line class="cls-1" x1="231.74" y1="63.37" x2="218.82" y2="70.83"/><line class="cls-1" x1="248.25" y1="127.99" x2="233.33" y2="127.99"/><line class="cls-1" x1="231.74" y1="186.62" x2="218.82" y2="179.17"/><line class="cls-1" x1="186.63" y1="231.74" x2="179.17" y2="218.82"/><line class="cls-1" x1="127.99" y1="248.25" x2="127.99" y2="233.33"/><line class="cls-1" x1="63.38" y1="231.74" x2="70.83" y2="218.82"/><line class="cls-1" x1="18.26" y1="186.63" x2="31.18" y2="179.17"/><line class="cls-1" x1="1.75" y1="122.01" x2="16.67" y2="122.01"/><line class="cls-1" x1="18.26" y1="63.38" x2="31.18" y2="70.83"/><line class="cls-1" x1="63.37" y1="18.26" x2="70.83" y2="31.18"/><line class="cls-1" x1="127.99" y1="1.75" x2="127.99" y2="16.67"/><line class="cls-1" x1="248.25" y1="122.01" x2="233.33" y2="122.01"/><line class="cls-1" x1="122.01" y1="248.25" x2="122.01" y2="233.33"/><line class="cls-1" x1="1.75" y1="127.99" x2="16.67" y2="127.99"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

@ -0,0 +1,6 @@
# Module: Compliments
The `compliments` module is one of the default modules of the MagicMirror².
This module displays a random compliment.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/compliments.html).

@ -0,0 +1,181 @@
/* MagicMirror²
* Module: Compliments
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
Module.register("compliments", {
// Module config defaults.
defaults: {
compliments: {
anytime: ["Hey there sexy!"],
morning: ["Good morning, handsome!", "Enjoy your day!", "How was your sleep?"],
afternoon: ["Hello, beauty!", "You look sexy!", "Looking good today!"],
evening: ["Wow, you look hot!", "You look nice!", "Hi, sexy!"],
"....-01-01": ["Happy new year!"]
},
updateInterval: 30000,
remoteFile: null,
fadeSpeed: 4000,
morningStartTime: 3,
morningEndTime: 12,
afternoonStartTime: 12,
afternoonEndTime: 17,
random: true
},
lastIndexUsed: -1,
// Set currentweather from module
currentWeatherType: "",
// Define required scripts.
getScripts: function () {
return ["moment.js"];
},
// Define start sequence.
start: async function () {
Log.info(`Starting module: ${this.name}`);
this.lastComplimentIndex = -1;
if (this.config.remoteFile !== null) {
const response = await this.loadComplimentFile();
this.config.compliments = JSON.parse(response);
this.updateDom();
}
// Schedule update timer.
setInterval(() => {
this.updateDom(this.config.fadeSpeed);
}, this.config.updateInterval);
},
/**
* Generate a random index for a list of compliments.
* @param {string[]} compliments Array with compliments.
* @returns {number} a random index of given array
*/
randomIndex: function (compliments) {
if (compliments.length === 1) {
return 0;
}
const generate = function () {
return Math.floor(Math.random() * compliments.length);
};
let complimentIndex = generate();
while (complimentIndex === this.lastComplimentIndex) {
complimentIndex = generate();
}
this.lastComplimentIndex = complimentIndex;
return complimentIndex;
},
/**
* Retrieve an array of compliments for the time of the day.
* @returns {string[]} array with compliments for the time of the day.
*/
complimentArray: function () {
const hour = moment().hour();
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];
} else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime && this.config.compliments.hasOwnProperty("afternoon")) {
compliments = [...this.config.compliments.afternoon];
} else if (this.config.compliments.hasOwnProperty("evening")) {
compliments = [...this.config.compliments.evening];
}
// Add compliments based on weather
if (this.currentWeatherType in this.config.compliments) {
Array.prototype.push.apply(compliments, this.config.compliments[this.currentWeatherType]);
}
// 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)) {
Array.prototype.push.apply(compliments, this.config.compliments[entry]);
}
}
return compliments;
},
/**
* Retrieve a file from the local filesystem
* @returns {Promise} Resolved when the file is loaded
*/
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();
},
/**
* Retrieve a random compliment.
* @returns {string} a compliment
*/
getRandomCompliment: function () {
// get the current time of day compliments list
const compliments = this.complimentArray();
// variable for index to next message to display
let index;
// are we randomizing
if (this.config.random) {
// yes
index = this.randomIndex(compliments);
} else {
// no, sequential
// if doing sequential, don't fall off the end
index = this.lastIndexUsed >= compliments.length - 1 ? 0 : ++this.lastIndexUsed;
}
return compliments[index] || "";
},
// Override dom generator.
getDom: function () {
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.getRandomCompliment();
// split it into parts on newline text
const parts = complimentText.split("\n");
// 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) {
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);
}
return wrapper;
},
// Override notification handler.
notificationReceived: function (notification, payload, sender) {
if (notification === "CURRENTWEATHER_TYPE") {
this.currentWeatherType = payload.type;
}
}
});

@ -0,0 +1,12 @@
/* MagicMirror² Default Modules List
* Modules listed below can be loaded without the 'default/' prefix. Omitting the default folder name.
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
const defaultModules = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather"];
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = defaultModules;
}

@ -0,0 +1,5 @@
# Module: Hello World
The `helloworld` module is one of the default modules of the MagicMirror². It is a simple way to display a static text on the mirror.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/helloworld.html).

@ -0,0 +1,20 @@
/* MagicMirror²
* Module: HelloWorld
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
Module.register("helloworld", {
// Default module config.
defaults: {
text: "Hello World!"
},
getTemplate: function () {
return "helloworld.njk";
},
getTemplateData: function () {
return this.config;
}
});

@ -0,0 +1,5 @@
<!--
Use ` | safe` to allow html tages within the text string.
https://mozilla.github.io/nunjucks/templating.html#autoescaping
-->
<div>{{text | safe}}</div>

@ -0,0 +1,6 @@
# Module: News Feed
The `newsfeed` module is one of the default modules of the MagicMirror².
This module displays news headlines based on an RSS feed. Scrolling through news headlines happens time-based (`updateInterval`), but can also be controlled by sending news feed specific notifications to the module.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/newsfeed.html).

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

@ -0,0 +1,24 @@
iframe.newsfeed-fullarticle {
width: 100vw;
/* very large height value to allow scrolling */
height: 3000px;
top: 0;
left: 0;
border: none;
z-index: 1;
}
.region.bottom.bar.newsfeed-fullarticle {
bottom: inherit;
top: -90px;
}
.newsfeed-list {
list-style: none;
}
.newsfeed-list li {
text-align: justify;
margin-bottom: 0.5em;
}

@ -0,0 +1,409 @@
/* MagicMirror²
* Module: NewsFeed
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
Module.register("newsfeed", {
// Default module config.
defaults: {
feeds: [
{
title: "New York Times",
url: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
encoding: "UTF-8" //ISO-8859-1
}
],
showAsList: false,
showSourceTitle: true,
showPublishDate: true,
broadcastNewsFeeds: true,
broadcastNewsUpdates: true,
showDescription: false,
showTitleAsUrl: false,
wrapTitle: true,
wrapDescription: true,
truncDescription: true,
lengthDescription: 400,
hideLoading: false,
reloadInterval: 5 * 60 * 1000, // every 5 minutes
updateInterval: 10 * 1000,
animationSpeed: 2.5 * 1000,
maxNewsItems: 0, // 0 for unlimited
ignoreOldItems: false,
ignoreOlderThan: 24 * 60 * 60 * 1000, // 1 day
removeStartTags: "",
removeEndTags: "",
startTags: [],
endTags: [],
prohibitedWords: [],
scrollLength: 500,
logFeedWarnings: false,
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"];
},
//Define required styles.
getStyles: function () {
return ["newsfeed.css"];
},
// Define required translations.
getTranslations: function () {
// The translations for the default modules are defined in the core translation files.
// Therefor we can just return false. Otherwise we should have returned a dictionary.
// If you're trying to build your own module including translations, check out the documentation.
return false;
},
// Define start sequence.
start: function () {
Log.info(`Starting module: ${this.name}`);
// Set locale.
moment.locale(config.language);
this.newsItems = [];
this.loaded = false;
this.error = null;
this.activeItem = 0;
this.scrollPosition = 0;
this.registerFeeds();
this.isShowingDescription = this.config.showDescription;
},
// Override socket notification handler.
socketNotificationReceived: function (notification, payload) {
if (notification === "NEWS_ITEMS") {
this.generateFeed(payload);
if (!this.loaded) {
if (this.config.hideLoading) {
this.show();
}
this.scheduleUpdateInterval();
}
this.loaded = true;
this.error = null;
} else if (notification === "NEWSFEED_ERROR") {
this.error = this.translate(payload.error_type);
this.scheduleUpdateInterval();
}
},
//Override fetching of template name
getTemplate: function () {
if (this.config.feedUrl) {
return "oldconfig.njk";
} else if (this.config.showFullArticle) {
return "fullarticle.njk";
}
return "newsfeed.njk";
},
//Override template data and return whats used for the current template
getTemplateData: function () {
// this.config.showFullArticle is a run-time configuration, triggered by optional notifications
if (this.config.showFullArticle) {
return {
url: this.getActiveItemURL()
};
}
if (this.error) {
return {
error: this.error
};
}
if (this.newsItems.length === 0) {
return {
empty: true
};
}
if (this.activeItem >= this.newsItems.length) {
this.activeItem = 0;
}
const item = this.newsItems[this.activeItem];
const items = this.newsItems.map(function (item) {
item.publishDate = moment(new Date(item.pubdate)).fromNow();
return item;
});
return {
loaded: true,
config: this.config,
sourceTitle: item.sourceTitle,
publishDate: moment(new Date(item.pubdate)).fromNow(),
title: item.title,
url: this.getUrlPrefix(item) + item.url,
description: item.description,
items: items
};
},
getActiveItemURL: function () {
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 "";
}
},
/**
* Registers the feeds to be used by the backend.
*/
registerFeeds: function () {
for (let feed of this.config.feeds) {
this.sendSocketNotification("ADD_FEED", {
feed: feed,
config: this.config
});
}
},
/**
* Generate an ordered list of items for this configured module.
* @param {object} feeds An object with feeds returned by the node helper.
*/
generateFeed: function (feeds) {
let newsItems = [];
for (let feed in feeds) {
const feedItems = feeds[feed];
if (this.subscribedToFeed(feed)) {
for (let item of feedItems) {
item.sourceTitle = this.titleForFeed(feed);
if (!(this.config.ignoreOldItems && Date.now() - new Date(item.pubdate) > this.config.ignoreOlderThan)) {
newsItems.push(item);
}
}
}
}
newsItems.sort(function (a, b) {
const dateA = new Date(a.pubdate);
const dateB = new Date(b.pubdate);
return dateB - dateA;
});
if (this.config.maxNewsItems > 0) {
newsItems = newsItems.slice(0, this.config.maxNewsItems);
}
if (this.config.prohibitedWords.length > 0) {
newsItems = newsItems.filter(function (item) {
for (let word of this.config.prohibitedWords) {
if (item.title.toLowerCase().indexOf(word.toLowerCase()) > -1) {
return false;
}
}
return true;
}, this);
}
newsItems.forEach((item) => {
//Remove selected tags from the beginning of rss feed items (title or description)
if (this.config.removeStartTags === "title" || this.config.removeStartTags === "both") {
for (let startTag of this.config.startTags) {
if (item.title.slice(0, startTag.length) === startTag) {
item.title = item.title.slice(startTag.length, item.title.length);
}
}
}
if (this.config.removeStartTags === "description" || this.config.removeStartTags === "both") {
if (this.isShowingDescription) {
for (let startTag of this.config.startTags) {
if (item.description.slice(0, startTag.length) === startTag) {
item.description = item.description.slice(startTag.length, item.description.length);
}
}
}
}
//Remove selected tags from the end of rss feed items (title or description)
if (this.config.removeEndTags) {
for (let endTag of this.config.endTags) {
if (item.title.slice(-endTag.length) === endTag) {
item.title = item.title.slice(0, -endTag.length);
}
}
if (this.isShowingDescription) {
for (let endTag of this.config.endTags) {
if (item.description.slice(-endTag.length) === endTag) {
item.description = item.description.slice(0, -endTag.length);
}
}
}
}
});
// get updated news items and broadcast them
const updatedItems = [];
newsItems.forEach((value) => {
if (this.newsItems.findIndex((value1) => value1 === value) === -1) {
// Add item to updated items list
updatedItems.push(value);
}
});
// check if updated items exist, if so and if we should broadcast these updates, then lets do so
if (this.config.broadcastNewsUpdates && updatedItems.length > 0) {
this.sendNotification("NEWS_FEED_UPDATE", { items: updatedItems });
}
this.newsItems = newsItems;
},
/**
* Check if this module is configured to show this feed.
* @param {string} feedUrl Url of the feed to check.
* @returns {boolean} True if it is subscribed, false otherwise
*/
subscribedToFeed: function (feedUrl) {
for (let feed of this.config.feeds) {
if (feed.url === feedUrl) {
return true;
}
}
return false;
},
/**
* Returns title for the specific feed url.
* @param {string} feedUrl Url of the feed
* @returns {string} The title of the feed
*/
titleForFeed: function (feedUrl) {
for (let feed of this.config.feeds) {
if (feed.url === feedUrl) {
return feed.title || "";
}
}
return "";
},
/**
* Schedule visual update.
*/
scheduleUpdateInterval: function () {
this.updateDom(this.config.animationSpeed);
// Broadcast NewsFeed if needed
if (this.config.broadcastNewsFeeds) {
this.sendNotification("NEWS_FEED", { items: this.newsItems });
}
// #2638 Clear timer if it already exists
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => {
this.activeItem++;
this.updateDom(this.config.animationSpeed);
// Broadcast NewsFeed if needed
if (this.config.broadcastNewsFeeds) {
this.sendNotification("NEWS_FEED", { items: this.newsItems });
}
}, this.config.updateInterval);
},
resetDescrOrFullArticleAndTimer: function () {
this.isShowingDescription = this.config.showDescription;
this.config.showFullArticle = false;
this.scrollPosition = 0;
// reset bottom bar alignment
document.getElementsByClassName("region bottom bar")[0].classList.remove("newsfeed-fullarticle");
if (!this.timer) {
this.scheduleUpdateInterval();
}
},
notificationReceived: function (notification, payload, sender) {
const before = this.activeItem;
if (notification === "MODULE_DOM_CREATED" && this.config.hideLoading) {
this.hide();
} else if (notification === "ARTICLE_NEXT") {
this.activeItem++;
if (this.activeItem >= this.newsItems.length) {
this.activeItem = 0;
}
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
this.updateDom(100);
} else if (notification === "ARTICLE_PREVIOUS") {
this.activeItem--;
if (this.activeItem < 0) {
this.activeItem = this.newsItems.length - 1;
}
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
this.updateDom(100);
}
// if "more details" is received the first time: show article summary, on second time show full article
else if (notification === "ARTICLE_MORE_DETAILS") {
// full article is already showing, so scrolling down
if (this.config.showFullArticle === true) {
this.scrollPosition += this.config.scrollLength;
window.scrollTo(0, this.scrollPosition);
Log.debug(`${this.name} - scrolling down`);
Log.debug(`${this.name} - ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);
} else {
this.showFullArticle();
}
} else if (notification === "ARTICLE_SCROLL_UP") {
if (this.config.showFullArticle === true) {
this.scrollPosition -= this.config.scrollLength;
window.scrollTo(0, this.scrollPosition);
Log.debug(`${this.name} - scrolling up`);
Log.debug(`${this.name} - ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);
}
} else if (notification === "ARTICLE_LESS_DETAILS") {
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - showing only article titles again`);
this.updateDom(100);
} else if (notification === "ARTICLE_TOGGLE_FULL") {
if (this.config.showFullArticle) {
this.activeItem++;
this.resetDescrOrFullArticleAndTimer();
} else {
this.showFullArticle();
}
} else if (notification === "ARTICLE_INFO_REQUEST") {
this.sendNotification("ARTICLE_INFO_RESPONSE", {
title: this.newsItems[this.activeItem].title,
source: this.newsItems[this.activeItem].sourceTitle,
date: this.newsItems[this.activeItem].pubdate,
desc: this.newsItems[this.activeItem].description,
url: this.getActiveItemURL()
});
}
},
showFullArticle: function () {
this.isShowingDescription = !this.isShowingDescription;
this.config.showFullArticle = !this.isShowingDescription;
// make bottom bar align to top to allow scrolling
if (this.config.showFullArticle === true) {
document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle");
}
clearInterval(this.timer);
this.timer = null;
Log.debug(`${this.name} - showing ${this.isShowingDescription ? "article description" : "full article"}`);
this.updateDom(100);
}
});

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

@ -0,0 +1,183 @@
/* MagicMirror²
* Node Helper: Newsfeed - NewsfeedFetcher
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
const stream = require("stream");
const FeedMe = require("feedme");
const iconv = require("iconv-lite");
const fetch = require("fetch");
const Log = require("logger");
const NodeHelper = require("node_helper");
/**
* Responsible for requesting an update on the set interval and broadcasting the data.
* @param {string} url URL of the news feed.
* @param {number} reloadInterval Reload interval in milliseconds.
* @param {string} encoding Encoding of the feed.
* @param {boolean} logFeedWarnings If true log warnings when there is an error parsing a news article.
* @param {boolean} useCorsProxy If true cors proxy is used for article url's.
* @class
*/
const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy) {
let reloadTimer = null;
let items = [];
let reloadIntervalMS = reloadInterval;
let fetchFailedCallback = function () {};
let itemsReceivedCallback = function () {};
if (reloadIntervalMS < 1000) {
reloadIntervalMS = 1000;
}
/* private methods */
/**
* Request the new items.
*/
const fetchNews = () => {
clearTimeout(reloadTimer);
reloadTimer = null;
items = [];
const parser = new FeedMe();
parser.on("item", (item) => {
const title = item.title;
let description = item.description || item.summary || item.content || "";
const pubdate = item.pubdate || item.published || item.updated || item["dc:date"];
const url = item.url || item.link || "";
if (title && pubdate) {
const regex = /(<([^>]+)>)/gi;
description = description.toString().replace(regex, "");
items.push({
title: title,
description: description,
pubdate: pubdate,
url: url,
useCorsProxy: useCorsProxy
});
} else if (logFeedWarnings) {
Log.warn("Can't parse feed item:");
Log.warn(item);
Log.warn(`Title: ${title}`);
Log.warn(`Description: ${description}`);
Log.warn(`Pubdate: ${pubdate}`);
}
});
parser.on("end", () => {
this.broadcastItems();
});
parser.on("error", (error) => {
fetchFailedCallback(this, error);
scheduleTimer();
});
//"end" event is not broadcast if the feed is empty but "finish" is used for both
parser.on("finish", () => {
scheduleTimer();
});
parser.on("ttl", (minutes) => {
try {
// 86400000 = 24 hours is mentioned in the docs as maximum value:
const ttlms = Math.min(minutes * 60 * 1000, 86400000);
if (ttlms > reloadIntervalMS) {
reloadIntervalMS = ttlms;
Log.info(`Newsfeed-Fetcher: reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`);
}
} catch (error) {
Log.warn(`Newsfeed-Fetcher: feed ttl is no valid integer=${minutes} for url ${url}`);
}
});
const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
const headers = {
"User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`,
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
Pragma: "no-cache"
};
fetch(url, { headers: headers })
.then(NodeHelper.checkFetchStatus)
.then((response) => {
let nodeStream;
if (response.body instanceof stream.Readable) {
nodeStream = response.body;
} else {
nodeStream = stream.Readable.fromWeb(response.body);
}
nodeStream.pipe(iconv.decodeStream(encoding)).pipe(parser);
})
.catch((error) => {
fetchFailedCallback(this, error);
scheduleTimer();
});
};
/**
* Schedule the timer for the next update.
*/
const scheduleTimer = function () {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchNews();
}, reloadIntervalMS);
};
/* public methods */
/**
* Update the reload interval, but only if we need to increase the speed.
* @param {number} interval Interval for the update in milliseconds.
*/
this.setReloadInterval = function (interval) {
if (interval > 1000 && interval < reloadIntervalMS) {
reloadIntervalMS = interval;
}
};
/**
* Initiate fetchNews();
*/
this.startFetch = function () {
fetchNews();
};
/**
* Broadcast the existing items.
*/
this.broadcastItems = function () {
if (items.length <= 0) {
Log.info("Newsfeed-Fetcher: No items to broadcast yet.");
return;
}
Log.info(`Newsfeed-Fetcher: Broadcasting ${items.length} items.`);
itemsReceivedCallback(this);
};
this.onReceive = function (callback) {
itemsReceivedCallback = callback;
};
this.onError = function (callback) {
fetchFailedCallback = callback;
};
this.url = function () {
return url;
};
this.items = function () {
return items;
};
};
module.exports = NewsfeedFetcher;

@ -0,0 +1,86 @@
/* MagicMirror²
* Node Helper: Newsfeed
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
const NodeHelper = require("node_helper");
const Log = require("logger");
const NewsfeedFetcher = require("./newsfeedfetcher");
module.exports = NodeHelper.create({
// Override start method.
start: function () {
Log.log(`Starting node helper for: ${this.name}`);
this.fetchers = [];
},
// Override socketNotificationReceived received.
socketNotificationReceived: function (notification, payload) {
if (notification === "ADD_FEED") {
this.createFetcher(payload.feed, payload.config);
}
},
/**
* Creates a fetcher for a new feed if it doesn't exist yet.
* Otherwise it reuses the existing one.
* @param {object} feed The feed object
* @param {object} config The configuration object
*/
createFetcher: function (feed, config) {
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);
} catch (error) {
Log.error("Newsfeed Error. Malformed newsfeed url: ", url, error);
this.sendSocketNotification("NEWSFEED_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
return;
}
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, useCorsProxy);
fetcher.onReceive(() => {
this.broadcastFeeds();
});
fetcher.onError((fetcher, error) => {
Log.error("Newsfeed Error. Could not fetch newsfeed: ", url, error);
let error_type = NodeHelper.checkFetchError(error);
this.sendSocketNotification("NEWSFEED_ERROR", {
error_type
});
});
this.fetchers[url] = fetcher;
} else {
Log.log(`Use existing newsfetcher for url: ${url}`);
fetcher = this.fetchers[url];
fetcher.setReloadInterval(reloadInterval);
fetcher.broadcastItems();
}
fetcher.startFetch();
},
/**
* Creates an object with all feed items of the different registered feeds,
* and broadcasts these using sendSocketNotification.
*/
broadcastFeeds: function () {
const feeds = {};
for (let f in this.fetchers) {
feeds[f] = this.fetchers[f].items();
}
this.sendSocketNotification("NEWS_ITEMS", feeds);
}
});

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

@ -0,0 +1,6 @@
# Module: Update Notification
The `updatenotification` module is one of the default modules of the MagicMirror².
This will display a message whenever a new version of the MagicMirror² application is available.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/updatenotification.html).

@ -0,0 +1,213 @@
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const fs = require("fs");
const path = require("path");
const Log = require("logger");
const BASE_DIR = path.normalize(`${__dirname}/../../../`);
class GitHelper {
constructor() {
this.gitRepos = [];
this.gitResultList = [];
}
getRefRegex(branch) {
return new RegExp(`s*([a-z,0-9]+[.][.][a-z,0-9]+) ${branch}`, "g");
}
async execShell(command) {
const { stdout = "", stderr = "" } = await exec(command);
return { stdout, stderr };
}
async isGitRepo(moduleFolder) {
const { stderr } = await this.execShell(`cd ${moduleFolder} && git remote -v`);
if (stderr) {
Log.error(`Failed to fetch git data for ${moduleFolder}: ${stderr}`);
return false;
}
return true;
}
async add(moduleName) {
let moduleFolder = BASE_DIR;
if (moduleName !== "MagicMirror") {
moduleFolder = `${moduleFolder}modules/${moduleName}`;
}
try {
Log.info(`Checking git for module: ${moduleName}`);
// Throws error if file doesn't exist
fs.statSync(path.join(moduleFolder, ".git"));
// Fetch the git or throw error if no remotes
const isGitRepo = await this.isGitRepo(moduleFolder);
if (isGitRepo) {
// Folder has .git and has at least one git remote, watch this folder
this.gitRepos.push({ module: moduleName, folder: moduleFolder });
}
} catch (err) {
// Error when directory .git doesn't exist or doesn't have any remotes
// This module is not managed with git, skip
}
}
async getStatusInfo(repo) {
let gitInfo = {
module: repo.module,
behind: 0, // commits behind
current: "", // branch name
hash: "", // current hash
tracking: "", // remote branch
isBehindInStatus: false
};
if (repo.module === "MagicMirror") {
// the hash is only needed for the mm repo
const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git rev-parse HEAD`);
if (stderr) {
Log.error(`Failed to get current commit hash for ${repo.module}: ${stderr}`);
}
gitInfo.hash = stdout;
}
const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git status -sb`);
if (stderr) {
Log.error(`Failed to get git status for ${repo.module}: ${stderr}`);
// exit without git status info
return;
}
// only the first line of stdout is evaluated
let status = stdout.split("\n")[0];
// examples for status:
// ## develop...origin/develop
// ## master...origin/master [behind 8]
// ## master...origin/master [ahead 8, behind 1]
// ## HEAD (no branch)
status = status.match(/## (.*)\.\.\.([^ ]*)(?: .*behind (\d+))?/);
// examples for status:
// [ '## 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' ]
if (status) {
gitInfo.current = status[1];
gitInfo.tracking = status[2];
if (status[3]) {
// git fetch was already called before so `git status -sb` delivers already the behind number
gitInfo.behind = parseInt(status[3]);
gitInfo.isBehindInStatus = true;
}
}
return gitInfo;
}
async getRepoInfo(repo) {
const gitInfo = await this.getStatusInfo(repo);
if (!gitInfo || !gitInfo.current) {
return;
}
if (gitInfo.isBehindInStatus && (gitInfo.module !== "MagicMirror" || gitInfo.current !== "master")) {
return gitInfo;
}
const { stderr } = await this.execShell(`cd ${repo.folder} && git fetch -n --dry-run`);
// example output:
// From https://github.com/MichMich/MagicMirror
// e40ddd4..06389e3 develop -> origin/develop
// here the result is in stderr (this is a git default, don't ask why ...)
const matches = stderr.match(this.getRefRegex(gitInfo.current));
// this is the default if there was no match from "git fetch -n --dry-run".
// Its a fallback because if there was a real "git fetch", the above "git fetch -n --dry-run" would deliver nothing.
let refDiff = `${gitInfo.current}..origin/${gitInfo.current}`;
if (matches && matches[0]) {
refDiff = matches[0];
}
// get behind with refs
try {
const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path --count ${refDiff}`);
gitInfo.behind = parseInt(stdout);
// for MagicMirror-Repo and "master" branch avoid getting notified when no tag is in refDiff
// so only releases are reported and we can change e.g. the README.md without sending notifications
if (gitInfo.behind > 0 && gitInfo.module === "MagicMirror" && gitInfo.current === "master") {
let tagList = "";
try {
const { stdout } = await this.execShell(`cd ${repo.folder} && git ls-remote -q --tags --refs`);
tagList = stdout.trim();
} catch (err) {
Log.error(`Failed to get tag list for ${repo.module}: ${err}`);
}
// check if tag is between commits and only report behind > 0 if so
try {
const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path ${refDiff}`);
let cnt = 0;
for (const ref of stdout.trim().split("\n")) {
if (tagList.includes(ref)) cnt++; // tag found
}
if (cnt === 0) gitInfo.behind = 0;
} catch (err) {
Log.error(`Failed to get git revisions for ${repo.module}: ${err}`);
}
}
return gitInfo;
} catch (err) {
Log.error(`Failed to get git revisions for ${repo.module}: ${err}`);
}
}
async getRepos() {
this.gitResultList = [];
for (const repo of this.gitRepos) {
try {
const gitInfo = await this.getRepoInfo(repo);
if (gitInfo) {
this.gitResultList.push(gitInfo);
}
} catch (e) {
Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`);
}
}
return this.gitResultList;
}
async checkUpdates() {
var updates = [];
const allRepos = await this.gitResultList.map((module) => {
return new Promise((resolve) => {
if (module.behind > 0 && module.module !== "MagicMirror") {
Log.info(`Update found for module: ${module.module}`);
updates.push(module);
}
resolve(module);
});
});
await Promise.all(allRepos);
return updates;
}
}
module.exports = GitHelper;

@ -0,0 +1,87 @@
const NodeHelper = require("node_helper");
const defaultModules = require("../defaultmodules");
const GitHelper = require("./git_helper");
const ONE_MINUTE = 60 * 1000;
module.exports = NodeHelper.create({
config: {},
updateTimer: null,
updateProcessStarted: false,
gitHelper: new GitHelper(),
async configureModules(modules) {
for (const moduleName of modules) {
if (!this.ignoreUpdateChecking(moduleName)) {
await this.gitHelper.add(moduleName);
}
}
if (!this.ignoreUpdateChecking("MagicMirror")) {
await this.gitHelper.add("MagicMirror");
}
},
async socketNotificationReceived(notification, payload) {
switch (notification) {
case "CONFIG":
this.config = payload;
break;
case "MODULES":
// if this is the 1st time thru the update check process
if (!this.updateProcessStarted) {
this.updateProcessStarted = true;
await this.configureModules(payload);
await this.performFetch();
}
break;
case "SCAN_UPDATES":
// 1st time of check allows to force new scan
if (this.updateProcessStarted) {
clearTimeout(this.updateTimer);
await this.performFetch();
}
break;
}
},
async performFetch() {
const repos = await this.gitHelper.getRepos();
for (const repo of repos) {
this.sendSocketNotification("STATUS", repo);
}
if (this.config.sendUpdatesNotifications) {
const updates = await this.gitHelper.checkUpdates();
if (updates.length) this.sendSocketNotification("UPDATES", updates);
}
this.scheduleNextFetch(this.config.updateInterval);
},
scheduleNextFetch(delay) {
clearTimeout(this.updateTimer);
this.updateTimer = setTimeout(() => {
this.performFetch();
}, Math.max(delay, ONE_MINUTE));
},
ignoreUpdateChecking(moduleName) {
// Should not check for updates for default modules
if (defaultModules.includes(moduleName)) {
return true;
}
// Should not check for updates for ignored modules
if (this.config.ignoreModules.includes(moduleName)) {
return true;
}
// The rest of the modules that passes should check for updates
return false;
}
});

@ -0,0 +1,3 @@
.module.updatenotification a.difflink {
text-decoration: none;
}

@ -0,0 +1,100 @@
/* MagicMirror²
* Module: UpdateNotification
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
Module.register("updatenotification", {
defaults: {
updateInterval: 10 * 60 * 1000, // every 10 minutes
refreshInterval: 24 * 60 * 60 * 1000, // one day
ignoreModules: [],
sendUpdatesNotifications: false
},
suspended: false,
moduleList: {},
start() {
Log.info(`Starting module: ${this.name}`);
this.addFilters();
setInterval(() => {
this.moduleList = {};
this.updateDom(2);
}, this.config.refreshInterval);
},
suspend() {
this.suspended = true;
},
resume() {
this.suspended = false;
this.updateDom(2);
},
notificationReceived(notification) {
switch (notification) {
case "DOM_OBJECTS_CREATED":
this.sendSocketNotification("CONFIG", this.config);
this.sendSocketNotification("MODULES", Object.keys(Module.definitions));
break;
case "SCAN_UPDATES":
this.sendSocketNotification("SCAN_UPDATES");
break;
}
},
socketNotificationReceived(notification, payload) {
switch (notification) {
case "STATUS":
this.updateUI(payload);
break;
case "UPDATES":
this.sendNotification("UPDATES", payload);
break;
}
},
getStyles() {
return [`${this.name}.css`];
},
getTemplate() {
return `${this.name}.njk`;
},
getTemplateData() {
return { moduleList: this.moduleList, suspended: this.suspended };
},
updateUI(payload) {
if (payload && payload.behind > 0) {
// if we haven't seen info for this module
if (this.moduleList[payload.module] === undefined) {
// save it
this.moduleList[payload.module] = payload;
this.updateDom(2);
}
} else if (payload && payload.behind === 0) {
// if the module WAS in the list, but shouldn't be
if (this.moduleList[payload.module] !== undefined) {
// remove it
delete this.moduleList[payload.module];
this.updateDom(2);
}
}
},
addFilters() {
this.nunjucksEnvironment().addFilter("diffLink", (text, status) => {
if (status.module !== "MagicMirror") {
return text;
}
const localRef = status.hash;
const remoteRef = status.tracking.replace(/.*\//, "");
return `<a href="https://github.com/MichMich/MagicMirror/compare/${localRef}...${remoteRef}" class="xsmall dimmed difflink" target="_blank">${text}</a>`;
});
}
});

@ -0,0 +1,15 @@
{% if not suspended %}
{% for name, status in moduleList %}
<div class="small bright">
<i class="fas fa-exclamation-circle"></i>
<span>
{% set mainTextLabel = "UPDATE_NOTIFICATION" if name === "MagicMirror" else "UPDATE_NOTIFICATION_MODULE" %}
{{ mainTextLabel | translate({MODULE_NAME: name}) }}
</span>
</div>
<div class="xsmall dimmed">
{% set subTextLabel = "UPDATE_INFO_SINGLE" if status.behind === 1 else "UPDATE_INFO_MULTIPLE" %}
{{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }}
</div>
{% endfor %}
{% endif %}

@ -0,0 +1,172 @@
/**
* 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 receive
* @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property).
*/
async function performWebRequest(url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined) {
const request = {};
let requestUrl;
if (useCorsProxy) {
requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders);
} else {
requestUrl = url;
request.headers = getHeadersToSend(requestHeaders);
}
const response = await fetch(requestUrl, 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 receive
* @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 attach to the web request.
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @returns {object} An object specifying name and value of the headers.
*/
const getHeadersToSend = (requestHeaders) => {
const headersToSend = {};
if (requestHeaders) {
for (const header of requestHeaders) {
headersToSend[header.name] = header.value;
}
}
return headersToSend;
};
/**
* Gets the part of the CORS URL that represents the expected HTTP headers to receive.
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
* @returns {string} to be used as the expected HTTP-headers component in CORS URL.
*/
const getExpectedResponseHeadersString = function (expectedResponseHeaders) {
let expectedResponseHeadersString = "";
if (expectedResponseHeaders) {
for (const header of expectedResponseHeaders) {
if (expectedResponseHeadersString.length === 0) {
expectedResponseHeadersString = `${header}`;
} else {
expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`;
}
}
return expectedResponseHeaders;
}
return undefined;
};
/**
* Gets the values for the expected headers from the response.
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
* @param {Response} response the HTTP response
* @returns {string} to be used as the expected HTTP-headers component in CORS URL.
*/
const getHeadersFromResponse = (expectedResponseHeaders, response) => {
const responseHeaders = [];
if (expectedResponseHeaders) {
for (const header of expectedResponseHeaders) {
const headerValue = response.headers.get(header);
responseHeaders.push({ name: header, value: headerValue });
}
}
return responseHeaders;
};
/**
* Format the time according to the config
* @param {object} config The config of the module
* @param {object} time time to format
* @returns {string} The formatted time string
*/
const formatTime = (config, time) => {
let date = moment(time);
if (config.timezone) {
date = date.tz(config.timezone);
}
if (config.timeFormat !== 24) {
if (config.showPeriod) {
if (config.showPeriodUpper) {
return date.format("h:mm A");
} else {
return date.format("h:mm a");
}
} else {
return date.format("h:mm");
}
}
return date.format("HH:mm");
};
if (typeof module !== "undefined")
module.exports = {
performWebRequest,
formatTime
};

@ -0,0 +1,5 @@
# Weather Module
This module will be configurable to be used as a current weather view, or to show the forecast. This way the module can be used twice to fulfill both purposes.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/weather.html).

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

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

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

@ -0,0 +1,3 @@
# Weather Module Weather Provider Development Documentation
For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/development/weather-provider.html).

@ -0,0 +1,572 @@
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
* Provider: Environment Canada (EC)
*
* This class is a provider for Environment Canada MSC Datamart
* Note that this is only for Canadian locations and does not require an API key (access is anonymous)
*
* EC Documentation at following links:
* https://dd.weather.gc.ca/citypage_weather/schema/
* https://eccc-msc.github.io/open-data/msc-datamart/readme_en/
*
* This module supports Canadian locations only and requires 2 additional config parameters:
*
* siteCode - the city/town unique identifier for which weather is to be displayed. Format is 's0000000'.
*
* provCode - the 2-character province code for the selected city/town.
*
* Example: for Toronto, Ontario, the following parameters would be used
*
* siteCode: 's0000458',
* provCode: 'ON'
*
* To determine the siteCode and provCode values for a Canadian city/town, look at the Environment Canada document
* at https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv (or site_list_fr.csv). There you will find a table
* with locations you can search under column B (English Names), with the corresponding siteCode under
* column A (Codes) and provCode under column C (Province).
*
* Original by Kevin Godin
*
* License to use Environment Canada (EC) data is detailed here:
* https://eccc-msc.github.io/open-data/licence/readme_en/
*
*/
WeatherProvider.register("envcanada", {
// Set the name of the provider for debugging and alerting purposes (eg. provide eye-catcher)
providerName: "Environment Canada",
// Set the default config properties that is specific to this provider
defaults: {
useCorsProxy: true,
siteCode: "s1234567",
provCode: "ON"
},
//
// Set config values (equates to weather module config values). Also set values pertaining to caching of
// Today's temperature forecast (for use in the Forecast functions below)
//
setConfig: function (config) {
this.config = config;
this.todayTempCacheMin = 0;
this.todayTempCacheMax = 0;
this.todayCached = false;
this.cacheCurrentTemp = 999;
},
//
// Called when the weather provider is started
//
start: function () {
Log.info(`Weather provider: ${this.providerName} started.`);
this.setFetchedLocation(this.config.location);
},
//
// Override the fetchCurrentWeather method to query EC and construct a Current weather object
//
fetchCurrentWeather() {
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
return;
}
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load EnvCanada site data ... ", request);
})
.finally(() => this.updateAvailable());
},
//
// Override the fetchWeatherForecast method to query EC and construct Forecast weather objects
//
fetchWeatherForecast() {
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
return;
}
const forecastWeather = this.generateWeatherObjectsFromForecast(data);
this.setWeatherForecast(forecastWeather);
})
.catch(function (request) {
Log.error("Could not load EnvCanada forecast data ... ", request);
})
.finally(() => this.updateAvailable());
},
//
// Override the fetchWeatherHourly method to query EC and construct Forecast weather objects
//
fetchWeatherHourly() {
this.fetchData(this.getUrl(), "xml")
.then((data) => {
if (!data) {
// Did not receive usable new data.
return;
}
const hourlyWeather = this.generateWeatherObjectsFromHourly(data);
this.setWeatherHourly(hourlyWeather);
})
.catch(function (request) {
Log.error("Could not load EnvCanada hourly data ... ", request);
})
.finally(() => this.updateAvailable());
},
//////////////////////////////////////////////////////////////////////////////////
//
// Environment Canada methods - not part of the standard Provider methods
//
//////////////////////////////////////////////////////////////////////////////////
//
// 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() {
return `https://dd.weather.gc.ca/citypage_weather/xml/${this.config.provCode}/${this.config.siteCode}_e.xml`;
},
//
// Generate a WeatherObject based on current EC weather conditions
//
generateWeatherObjectFromCurrentWeather(ECdoc) {
const currentWeather = new WeatherObject();
// There are instances where EC will update weather data and current temperature will not be
// provided. While this is a defect in the EC systems, we need to accommodate to avoid a current temp
// of NaN being displayed. Therefore... whenever we get a valid current temp from EC, we will cache
// the value. Whenever EC data is missing current temp, we will provide the cached value
// instead. This is reasonable since the cached value will typically be accurate within the previous
// hour. The only time this does not work as expected is when MM is restarted and the first query to
// EC finds no current temp. In this scenario, MM will end up displaying a current temp of null;
if (ECdoc.querySelector("siteData currentConditions temperature").textContent) {
currentWeather.temperature = ECdoc.querySelector("siteData currentConditions temperature").textContent;
this.cacheCurrentTemp = currentWeather.temperature;
} else {
currentWeather.temperature = this.cacheCurrentTemp;
}
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent;
currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent;
// Ensure showPrecipitationAmount is forced to false. EC does not really provide POP for current day
// and this feature for the weather module (current only) is sort of broken in that it wants
// to say POP but will display precip as an accumulated amount vs. a percentage.
this.config.showPrecipitationAmount = false;
//
// If the module config wants to showFeelsLike... default to the current temperature.
// Check for EC wind chill and humidex values and overwrite the feelsLikeTemp value.
// This assumes that the EC current conditions will never contain both a wind chill
// and humidex temperature.
//
if (this.config.showFeelsLike) {
currentWeather.feelsLikeTemp = currentWeather.temperature;
if (ECdoc.querySelector("siteData currentConditions windChill")) {
currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions windChill").textContent;
}
if (ECdoc.querySelector("siteData currentConditions humidex")) {
currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions humidex").textContent;
}
}
//
// Need to map EC weather icon to MM weatherType values
//
currentWeather.weatherType = this.convertWeatherType(ECdoc.querySelector("siteData currentConditions iconCode").textContent);
//
// Capture the sunrise and sunset values from EC data
//
const sunList = ECdoc.querySelectorAll("siteData riseSet dateTime");
currentWeather.sunrise = moment(sunList[1].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss");
currentWeather.sunset = moment(sunList[3].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss");
return currentWeather;
},
//
// Generate an array of WeatherObjects based on EC weather forecast
//
generateWeatherObjectsFromForecast(ECdoc) {
// Declare an array to hold each day's forecast object
const days = [];
const weather = new WeatherObject();
const foreBaseDates = ECdoc.querySelectorAll("siteData forecastGroup dateTime");
const baseDate = foreBaseDates[1].querySelector("timeStamp").textContent;
weather.date = moment(baseDate, "YYYYMMDDhhmmss");
const foreGroup = ECdoc.querySelectorAll("siteData forecastGroup forecast");
weather.precipitationAmount = null;
//
// The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing
// 2 elements. the first element for a day details the Today (daytime) forecast while the second
// element details the Tonight (nightime) forecast. Element 0 is always for the current day.
//
// However... the forecast is somewhat 'rolling'.
//
// If the EC forecast is queried in the morning, then Element 0 will contain Current
// Today and Element 1 will contain Current Tonight. From there, the next 5 days of forecast will be
// contained in Elements 2/3, 4/5, 6/7, 8/9, and 10/11. This module will create a 6-day forecast using
// all of these Elements.
//
// But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled
// off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in
// Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Elelement 11 will contain a forecast for a 6th day,
// but only for the Today portion (not Tonight). This module will create a 6-day forecast using
// Elements 0 to 11, and will ignore the additional Todat forecast in Element 11.
//
// We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight.
// This is required to understand how Min and Max temperature will be determined, and to understand
// where the next day's (aka Tomorrow's) forecast is located in the forecast array.
//
let nextDay = 0;
let lastDay = 0;
const currentTemp = ECdoc.querySelector("siteData currentConditions temperature").textContent;
//
// If the first Element is Current Today, look at Current Today and Current Tonight for the current day.
//
if (foreGroup[0].querySelector("period[textForecastName='Today']")) {
this.todaytempCacheMin = 0;
this.todaytempCacheMax = 0;
this.todayCached = true;
this.setMinMaxTemps(weather, foreGroup, 0, true, currentTemp);
this.setPrecipitation(weather, foreGroup, 0);
//
// Set the Element number that will reflect where the next day's forecast is located. Also set
// the Element number where the end of the forecast will be. This is important because of the
// rolling nature of the EC forecast. In the current scenario (Today and Tonight are present
// in elements 0 and 11, we know that we will have 6 full days of forecasts and we will use
// them. We will set lastDay such that we iterate through all 12 elements of the forecast.
//
nextDay = 2;
lastDay = 12;
}
//
// If the first Element is Current Tonight, look at Tonight only for the current day.
//
if (foreGroup[0].querySelector("period[textForecastName='Tonight']")) {
this.setMinMaxTemps(weather, foreGroup, 0, false, currentTemp);
this.setPrecipitation(weather, foreGroup, 0);
//
// Set the Element number that will reflect where the next day's forecast is located. Also set
// the Element number where the end of the forecast will be. This is important because of the
// rolling nature of the EC forecast. In the current scenario (only Current Tonight is present
// in Element 0, we know that we will have 6 full days of forecasts PLUS a half-day and
// forecast in the final element. Because we will only use full day forecasts, we set the
// lastDay number to ensure we ignore that final half-day (in forecast Element 11).
//
nextDay = 1;
lastDay = 11;
}
//
// Need to map EC weather icon to MM weatherType values. Always pick the first Element's icon to
// reflect either Today or Tonight depending on what the forecast is showing in Element 0.
//
weather.weatherType = this.convertWeatherType(foreGroup[0].querySelector("abbreviatedForecast iconCode").textContent);
// Push the weather object into the forecast array.
days.push(weather);
//
// Now do the rest of the forecast starting at nextDay. We will process each day using 2 EC
// forecast Elements. This will address the fact that the EC forecast always includes Today and
// Tonight for each day. This is why we iterate through the forecast by a a count of 2, with each
// iteration looking at the current Element and the next Element.
//
let lastDate = moment(baseDate, "YYYYMMDDhhmmss");
for (let stepDay = nextDay; stepDay < lastDay; stepDay += 2) {
let weather = new WeatherObject();
// Add 1 to the date to reflect the current forecast day we are building
lastDate = lastDate.add(1, "day");
weather.date = moment(lastDate);
// Capture the temperatures for the current Element and the next Element in order to set
// the Min and Max temperatures for the forecast
this.setMinMaxTemps(weather, foreGroup, stepDay, true, currentTemp);
weather.precipitationAmount = null;
this.setPrecipitation(weather, foreGroup, stepDay);
//
// Need to map EC weather icon to MM weatherType values. Always pick the first Element icon.
//
weather.weatherType = this.convertWeatherType(foreGroup[stepDay].querySelector("abbreviatedForecast iconCode").textContent);
// Push the weather object into the forecast array.
days.push(weather);
}
return days;
},
//
// Generate an array of WeatherObjects based on EC hourly weather forecast
//
generateWeatherObjectsFromHourly(ECdoc) {
// Declare an array to hold each hour's forecast object
const hours = [];
// Get local timezone UTC offset so that each hourly time can be calculated properly
const baseHours = ECdoc.querySelectorAll("siteData hourlyForecastGroup dateTime");
const hourOffset = baseHours[1].getAttribute("UTCOffset");
//
// The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding
// the forecast for the next 'on the hour' timeslot. This means the array is a rolling 24 hours.
//
const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast");
for (let stepHour = 0; stepHour < 24; stepHour += 1) {
const weather = new WeatherObject();
// Determine local time by applying UTC offset to the forecast timestamp
const foreTime = moment(hourGroup[stepHour].getAttribute("dateTimeUTC"), "YYYYMMDDhhmmss");
const currTime = foreTime.add(hourOffset, "hours");
weather.date = moment(currTime);
// Capture the temperature
weather.temperature = hourGroup[stepHour].querySelector("temperature").textContent;
// Capture Likelihood of Precipitation (LOP) and unit-of-measure values
const precipLOP = hourGroup[stepHour].querySelector("lop").textContent * 1.0;
if (precipLOP > 0) {
weather.precipitationProbability = precipLOP;
}
//
// Need to map EC weather icon to MM weatherType values. Always pick the first Element icon.
//
weather.weatherType = this.convertWeatherType(hourGroup[stepHour].querySelector("iconCode").textContent);
// Push the weather object into the forecast array.
hours.push(weather);
}
return hours;
},
//
// Determine Min and Max temp based on a supplied Forecast Element index and a boolen that denotes if
// the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only
//
setMinMaxTemps(weather, foreGroup, today, fullDay, currentTemp) {
const todayTemp = foreGroup[today].querySelector("temperatures temperature").textContent;
const todayClass = foreGroup[today].querySelector("temperatures temperature").getAttribute("class");
//
// The following logic is largely aimed at accommodating the Current day's forecast whereby we
// can have either Current Today+Current Tonight or only Current Tonight.
//
// If fullDay is false, then we only have Tonight for the current day's forecast - meaning we have
// lost a min or max temp value for the day. Therefore, we will see if we were able to cache the the
// Today forecast for the current day. If we have, we will use them. If we do not have the cached values,
// it means that MM or the Computer has been restarted since the time EC rolled off Today from the
// forecast. In this scenario, we will simply default to the Current Conditions temperature and then
// check the Tonight temperature.
//
if (fullDay === false) {
if (this.todayCached === true) {
weather.minTemperature = this.todayTempCacheMin;
weather.maxTemperature = this.todayTempCacheMax;
} else {
weather.minTemperature = currentTemp;
weather.maxTemperature = weather.minTemperature;
}
}
//
// We will check to see if the current Element's temperature is Low or High and set weather values
// accordingly. We will also check the condition where fullDay is true *and* we are looking at forecast
// element 0. This is a special case where we will cache temperature values so that we have them later
// in the current day when the Current Today element rolls off and we have Current Tonight only.
//
if (todayClass === "low") {
weather.minTemperature = todayTemp;
if (today === 0 && fullDay === true) {
this.todayTempCacheMin = weather.minTemperature;
}
}
if (todayClass === "high") {
weather.maxTemperature = todayTemp;
if (today === 0 && fullDay === true) {
this.todayTempCacheMax = weather.maxTemperature;
}
}
const nextTemp = foreGroup[today + 1].querySelector("temperatures temperature").textContent;
const nextClass = foreGroup[today + 1].querySelector("temperatures temperature").getAttribute("class");
if (fullDay === true) {
if (nextClass === "low") {
weather.minTemperature = nextTemp;
}
if (nextClass === "high") {
weather.maxTemperature = nextTemp;
}
}
},
//
// Check for a Precipitation forecast. EC can provide a forecast in 2 ways: either an accumulation figure
// or a POP percentage. If there is a POP, then that is what the module will show. If there is an accumulation,
// then it will be displayed ONLY if no POP is present.
//
// POP Logic: By default, we want to show the POP for 'daytime' since we are presuming that is what
// people are more interested in seeing. While EC provides a separate POP for daytime and nightime portions
// of each day, the weather module does not really allow for that view of a daily forecast. There we will
// ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show
// the nightime forecast after a certain point in the afternoon. As such, we will be showing the nightime POP
// (if one exists) in that specific scenario.
//
// Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what
// people are interested in seeing. While EC provides a separate accumulation for daytime and nightime portions
// of each day, the weather module does not really allow for that view of a daily forecast. There we will
// ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show
// the nightime forecast after a certain point in that specific scenario.
//
setPrecipitation(weather, foreGroup, today) {
if (foreGroup[today].querySelector("precipitation accumulation")) {
weather.precipitationAmount = foreGroup[today].querySelector("precipitation accumulation amount").textContent * 1.0;
weather.precipitationUnits = foreGroup[today].querySelector("precipitation accumulation amount").getAttribute("units");
}
// Check Today element for POP
const precipPOP = foreGroup[today].querySelector("abbreviatedForecast pop").textContent * 1.0;
if (precipPOP > 0) {
weather.precipitationProbability = precipPOP;
}
},
//
// Convert the icons to a more usable name.
//
convertWeatherType(weatherType) {
const weatherTypes = {
"00": "day-sunny",
"01": "day-sunny",
"02": "day-sunny-overcast",
"03": "day-cloudy",
"04": "day-cloudy",
"05": "day-cloudy",
"06": "day-sprinkle",
"07": "day-showers",
"08": "day-snow",
"09": "day-thunderstorm",
10: "cloud",
11: "showers",
12: "rain",
13: "rain",
14: "sleet",
15: "sleet",
16: "snow",
17: "snow",
18: "snow",
19: "thunderstorm",
20: "cloudy",
21: "cloudy",
22: "day-cloudy",
23: "day-haze",
24: "fog",
25: "snow-wind",
26: "sleet",
27: "sleet",
28: "rain",
29: "na",
30: "night-clear",
31: "night-clear",
32: "night-partly-cloudy",
33: "night-alt-cloudy",
34: "night-alt-cloudy",
35: "night-partly-cloudy",
36: "night-alt-showers",
37: "night-rain-mix",
38: "night-alt-snow",
39: "night-thunderstorm",
40: "snow-wind",
41: "tornado",
42: "tornado",
43: "windy",
44: "smoke",
45: "sandstorm",
46: "thunderstorm",
47: "thunderstorm",
48: "tornado"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
}
});

@ -0,0 +1,548 @@
/* 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,
pastDays: 0,
type: "current"
},
// https://open-meteo.com/en/docs
hourlyParams: [
// Air temperature at 2 meters above ground
"temperature_2m",
// Relative humidity at 2 meters above ground
"relativehumidity_2m",
// Dew point temperature at 2 meters above ground
"dewpoint_2m",
// Apparent temperature is the perceived feels-like temperature combining wind chill factor, relative humidity and solar radiation
"apparent_temperature",
// Atmospheric air pressure reduced to mean sea level (msl) or pressure at surface. Typically pressure on mean sea level is used in meteorology. Surface pressure gets lower with increasing elevation.
"pressure_msl",
"surface_pressure",
// Total cloud cover as an area fraction
"cloudcover",
// Low level clouds and fog up to 3 km altitude
"cloudcover_low",
// Mid level clouds from 3 to 8 km altitude
"cloudcover_mid",
// High level clouds from 8 km altitude
"cloudcover_high",
// Wind speed at 10, 80, 120 or 180 meters above ground. Wind speed on 10 meters is the standard level.
"windspeed_10m",
"windspeed_80m",
"windspeed_120m",
"windspeed_180m",
// Wind direction at 10, 80, 120 or 180 meters above ground
"winddirection_10m",
"winddirection_80m",
"winddirection_120m",
"winddirection_180m",
// Gusts at 10 meters above ground as a maximum of the preceding hour
"windgusts_10m",
// Shortwave solar radiation as average of the preceding hour. This is equal to the total global horizontal irradiation
"shortwave_radiation",
// Direct solar radiation as average of the preceding hour on the horizontal plane and the normal plane (perpendicular to the sun)
"direct_radiation",
"direct_normal_irradiance",
// Diffuse solar radiation as average of the preceding hour
"diffuse_radiation",
// Vapor Pressure Deificit (VPD) in kilopascal (kPa). For high VPD (>1.6), water transpiration of plants increases. For low VPD (<0.4), transpiration decreases
"vapor_pressure_deficit",
// Evapotranspration from land surface and plants that weather models assumes for this location. Available soil water is considered. 1 mm evapotranspiration per hour equals 1 liter of water per spare meter.
"evapotranspiration",
// ET₀ Reference Evapotranspiration of a well watered grass field. Based on FAO-56 Penman-Monteith equations ET₀ is calculated from temperature, wind speed, humidity and solar radiation. Unlimited soil water is assumed. ET₀ is commonly used to estimate the required irrigation for plants.
"et0_fao_evapotranspiration",
// Total precipitation (rain, showers, snow) sum of the preceding hour
"precipitation",
// Precipitation Probability
"precipitation_probability",
// UV index
"uv_index",
// Snowfall amount of the preceding hour in centimeters. For the water equivalent in millimeter, divide by 7. E.g. 7 cm snow = 10 mm precipitation water equivalent
"snowfall",
// Rain from large scale weather systems of the preceding hour in millimeter
"rain",
// Showers from convective precipitation in millimeters from the preceding hour
"showers",
// Weather condition as a numeric code. Follow WMO weather interpretation codes.
"weathercode",
// Snow depth on the ground
"snow_depth",
// Altitude above sea level of the 0°C level
"freezinglevel_height",
// Temperature in the soil at 0, 6, 18 and 54 cm depths. 0 cm is the surface temperature on land or water surface temperature on water.
"soil_temperature_0cm",
"soil_temperature_6cm",
"soil_temperature_18cm",
"soil_temperature_54cm",
// Average soil water content as volumetric mixing ratio at 0-1, 1-3, 3-9, 9-27 and 27-81 cm depths.
"soil_moisture_0_1cm",
"soil_moisture_1_3cm",
"soil_moisture_3_9cm",
"soil_moisture_9_27cm",
"soil_moisture_27_81cm"
],
dailyParams: [
// Maximum and minimum daily air temperature at 2 meters above ground
"temperature_2m_max",
"temperature_2m_min",
// Maximum and minimum daily apparent temperature
"apparent_temperature_min",
"apparent_temperature_max",
// Sum of daily precipitation (including rain, showers and snowfall)
"precipitation_sum",
// Sum of daily rain
"rain_sum",
// Sum of daily showers
"showers_sum",
// Sum of daily snowfall
"snowfall_sum",
// The number of hours with rain
"precipitation_hours",
// The most severe weather condition on a given day
"weathercode",
// Sun rise and set times
"sunrise",
"sunset",
// Maximum wind speed and gusts on a day
"windspeed_10m_max",
"windgusts_10m_max",
// Dominant wind direction
"winddirection_10m_dominant",
// The sum of solar radiation on a given day in Megajoules
"shortwave_radiation_sum",
//UV Index
"uv_index_max",
// Daily sum of ET₀ Reference Evapotranspiration of a well watered grass field
"et0_fao_evapotranspiration"
],
fetchedLocation: 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.pastDays ?? 0,
daily: this.dailyParams,
hourly: this.hourlyParams,
// Fixed units as metric
temperature_unit: "celsius",
windspeed_unit: "ms",
precipitation_unit: "mm"
};
const startDate = moment().startOf("day");
const endDate = moment(startDate)
.add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), "days")
.endOf("day");
params["start_date"] = startDate.format("YYYY-MM-DD");
switch (this.config.type) {
case "hourly":
case "daily":
case "forecast":
params["end_date"] = endDate.format("YYYY-MM-DD");
break;
case "current":
params["current_weather"] = true;
params["end_date"] = params["start_date"];
break;
default:
// Failsafe
return "";
}
return Object.keys(params)
.filter((key) => (params[key] ? 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();
currentWeather.date = weather.current_weather.time;
currentWeather.windSpeed = weather.current_weather.windspeed;
currentWeather.windFromDirection = weather.current_weather.winddirection;
currentWeather.sunrise = weather.daily[0].sunrise;
currentWeather.sunset = weather.daily[0].sunset;
currentWeather.temperature = parseFloat(weather.current_weather.temperature);
currentWeather.minTemperature = parseFloat(weather.daily[0].temperature_2m_min);
currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max);
currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime());
currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m);
currentWeather.rain = parseFloat(weather.hourly[h].rain);
currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10);
currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation);
currentWeather.precipitationProbability = parseFloat(weather.hourly[h].precipitation_probability);
currentWeather.uv_index = parseFloat(weather.hourly[h].uv_index);
return currentWeather;
},
// Implement WeatherForecast generator.
generateWeatherObjectsFromForecast(weathers) {
const days = [];
weathers.daily.forEach((weather, i) => {
const currentWeather = new WeatherObject();
currentWeather.date = weather.time;
currentWeather.windSpeed = weather.windspeed_10m_max;
currentWeather.windFromDirection = weather.winddirection_10m_dominant;
currentWeather.sunrise = weather.sunrise;
currentWeather.sunset = weather.sunset;
currentWeather.temperature = parseFloat((weather.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.precipitationAmount = parseFloat(weather.precipitation_sum);
currentWeather.precipitationProbability = parseFloat(weather.precipitation_probability);
currentWeather.uv_index = parseFloat(weather.uv_index_max);
days.push(currentWeather);
});
return days;
},
// Implement WeatherHourly generator.
generateWeatherObjectsFromHourly(weathers) {
const hours = [];
const now = moment();
weathers.hourly.forEach((weather, i) => {
if ((hours.length === 0 && weather.time.hour() <= now.hour()) || hours.length >= this.config.maxEntries) {
return;
}
const currentWeather = new WeatherObject();
const h = Math.ceil((i + 1) / 24) - 1;
currentWeather.date = weather.time;
currentWeather.windSpeed = weather.windspeed_10m;
currentWeather.windFromDirection = weather.winddirection_10m;
currentWeather.sunrise = weathers.daily[h].sunrise;
currentWeather.sunset = weathers.daily[h].sunset;
currentWeather.temperature = parseFloat(weather.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.precipitationAmount = parseFloat(weather.precipitation);
currentWeather.precipitationProbability = parseFloat(weather.precipitation_probability);
currentWeather.uv_index = parseFloat(weather.uv_index);
hours.push(currentWeather);
});
return hours;
},
// Map icons from Dark Sky to our icons.
convertWeatherType(weathercode, isDayTime) {
const weatherConditions = {
0: "clear",
1: "mainly-clear",
2: "partly-cloudy",
3: "overcast",
45: "fog",
48: "depositing-rime-fog",
51: "drizzle-light-intensity",
53: "drizzle-moderate-intensity",
55: "drizzle-dense-intensity",
56: "freezing-drizzle-light-intensity",
57: "freezing-drizzle-dense-intensity",
61: "rain-slight-intensity",
63: "rain-moderate-intensity",
65: "rain-heavy-intensity",
66: "freezing-rain-light-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"];
}
});

@ -0,0 +1,448 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*
* This class is the blueprint for a weather provider.
*/
WeatherProvider.register("openweathermap", {
// Set the name of the provider.
// This isn't strictly necessary, since it will fallback to the provider identifier
// But for debugging (and future alerts) it would be nice to have the real name.
providerName: "OpenWeatherMap",
// Set the default config properties that is specific to this provider
defaults: {
apiVersion: "2.5",
apiBase: "https://api.openweathermap.org/data/",
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
lon: 0,
apiKey: ""
},
// Overwrite the fetchCurrentWeather method.
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => {
let currentWeather;
if (this.config.weatherEndpoint === "/onecall") {
currentWeather = this.generateWeatherObjectsFromOnecall(data).current;
this.setFetchedLocation(`${data.timezone}`);
} else {
currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
}
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
// Overwrite the fetchWeatherForecast method.
fetchWeatherForecast() {
this.fetchData(this.getUrl())
.then((data) => {
let forecast;
let location;
if (this.config.weatherEndpoint === "/onecall") {
forecast = this.generateWeatherObjectsFromOnecall(data).days;
location = `${data.timezone}`;
} else {
forecast = this.generateWeatherObjectsFromForecast(data.list);
location = `${data.city.name}, ${data.city.country}`;
}
this.setWeatherForecast(forecast);
this.setFetchedLocation(location);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
// Overwrite the fetchWeatherHourly method.
fetchWeatherHourly() {
this.fetchData(this.getUrl())
.then((data) => {
if (!data) {
// Did not receive usable new data.
// Maybe this needs a better check?
return;
}
this.setFetchedLocation(`(${data.lat},${data.lon})`);
const weatherData = this.generateWeatherObjectsFromOnecall(data);
this.setWeatherHourly(weatherData.hours);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
/**
* Overrides method for setting config to check if endpoint is correct for hourly
* @param {object} config The configuration object
*/
setConfig(config) {
this.config = config;
if (!this.config.weatherEndpoint) {
switch (this.config.type) {
case "hourly":
this.config.weatherEndpoint = "/onecall";
break;
case "daily":
case "forecast":
this.config.weatherEndpoint = "/forecast";
break;
case "current":
this.config.weatherEndpoint = "/weather";
break;
default:
Log.error("weatherEndpoint not configured and could not resolve it based on type");
}
}
},
/** OpenWeatherMap Specific Methods - These are not part of the default provider methods */
/*
* Gets the complete url for the request
*/
getUrl() {
return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.getParams();
},
/*
* Generate a WeatherObject based on currentWeatherInformation
*/
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject();
currentWeather.date = moment.unix(currentWeatherData.dt);
currentWeather.humidity = currentWeatherData.main.humidity;
currentWeather.temperature = currentWeatherData.main.temp;
currentWeather.feelsLikeTemp = currentWeatherData.main.feels_like;
currentWeather.windSpeed = currentWeatherData.wind.speed;
currentWeather.windFromDirection = currentWeatherData.wind.deg;
currentWeather.weatherType = this.convertWeatherType(currentWeatherData.weather[0].icon);
currentWeather.sunrise = moment.unix(currentWeatherData.sys.sunrise);
currentWeather.sunset = moment.unix(currentWeatherData.sys.sunset);
return currentWeather;
},
/*
* Generate WeatherObjects based on forecast information
*/
generateWeatherObjectsFromForecast(forecasts) {
if (this.config.weatherEndpoint === "/forecast") {
return this.generateForecastHourly(forecasts);
} else if (this.config.weatherEndpoint === "/forecast/daily") {
return this.generateForecastDaily(forecasts);
}
// if weatherEndpoint does not match forecast or forecast/daily, what should be returned?
return [new WeatherObject()];
},
/*
* Generate WeatherObjects based on One Call forecast information
*/
generateWeatherObjectsFromOnecall(data) {
if (this.config.weatherEndpoint === "/onecall") {
return this.fetchOnecall(data);
}
// if weatherEndpoint does not match onecall, what should be returned?
return { current: new WeatherObject(), hours: [], days: [] };
},
/*
* Generate forecast information for 3-hourly forecast (available for free
* subscription).
*/
generateForecastHourly(forecasts) {
// initial variable declaration
const days = [];
// variables for temperature range and rain
let minTemp = [];
let maxTemp = [];
let rain = 0;
let snow = 0;
// variable for date
let date = "";
let weather = new WeatherObject();
for (const forecast of forecasts) {
if (date !== moment.unix(forecast.dt).format("YYYY-MM-DD")) {
// calculate minimum/maximum temperature, specify rain amount
weather.minTemperature = Math.min.apply(null, minTemp);
weather.maxTemperature = Math.max.apply(null, maxTemp);
weather.rain = rain;
weather.snow = snow;
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
// push weather information to days array
days.push(weather);
// create new weather-object
weather = new WeatherObject();
minTemp = [];
maxTemp = [];
rain = 0;
snow = 0;
// set new date
date = moment.unix(forecast.dt).format("YYYY-MM-DD");
// specify date
weather.date = moment.unix(forecast.dt);
// If the first value of today is later than 17:00, we have an icon at least!
weather.weatherType = this.convertWeatherType(forecast.weather[0].icon);
}
if (moment.unix(forecast.dt).format("H") >= 8 && moment.unix(forecast.dt).format("H") <= 17) {
weather.weatherType = this.convertWeatherType(forecast.weather[0].icon);
}
// the same day as before
// add values from forecast to corresponding variables
minTemp.push(forecast.main.temp_min);
maxTemp.push(forecast.main.temp_max);
if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain["3h"])) {
rain += forecast.rain["3h"];
}
if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow["3h"])) {
snow += forecast.snow["3h"];
}
}
// last day
// calculate minimum/maximum temperature, specify rain amount
weather.minTemperature = Math.min.apply(null, minTemp);
weather.maxTemperature = Math.max.apply(null, maxTemp);
weather.rain = rain;
weather.snow = snow;
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
// push weather information to days array
days.push(weather);
return days.slice(1);
},
/*
* Generate forecast information for daily forecast (available for paid
* subscription or old apiKey).
*/
generateForecastDaily(forecasts) {
// initial variable declaration
const days = [];
for (const forecast of forecasts) {
const weather = new WeatherObject();
weather.date = moment.unix(forecast.dt);
weather.minTemperature = forecast.temp.min;
weather.maxTemperature = forecast.temp.max;
weather.weatherType = this.convertWeatherType(forecast.weather[0].icon);
weather.rain = 0;
weather.snow = 0;
// forecast.rain not available if amount is zero
// The API always returns in millimeters
if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain)) {
weather.rain = forecast.rain;
}
// forecast.snow not available if amount is zero
// The API always returns in millimeters
if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow)) {
weather.snow = forecast.snow;
}
weather.precipitationAmount = weather.rain + weather.snow;
weather.precipitationProbability = forecast.pop ? forecast.pop * 100 : undefined;
days.push(weather);
}
return days;
},
/*
* Fetch One Call forecast information (available for free subscription).
* Factors in timezone offsets.
* Minutely forecasts are excluded for the moment, see getParams().
*/
fetchOnecall(data) {
let precip = false;
// get current weather, if requested
const current = new WeatherObject();
if (data.hasOwnProperty("current")) {
current.date = moment.unix(data.current.dt).utcOffset(data.timezone_offset / 60);
current.windSpeed = data.current.wind_speed;
current.windFromDirection = data.current.wind_deg;
current.sunrise = moment.unix(data.current.sunrise).utcOffset(data.timezone_offset / 60);
current.sunset = moment.unix(data.current.sunset).utcOffset(data.timezone_offset / 60);
current.temperature = data.current.temp;
current.weatherType = this.convertWeatherType(data.current.weather[0].icon);
current.humidity = data.current.humidity;
if (data.current.hasOwnProperty("rain") && !isNaN(data.current["rain"]["1h"])) {
current.rain = data.current["rain"]["1h"];
precip = true;
}
if (data.current.hasOwnProperty("snow") && !isNaN(data.current["snow"]["1h"])) {
current.snow = data.current["snow"]["1h"];
precip = true;
}
if (precip) {
current.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0);
}
current.feelsLikeTemp = data.current.feels_like;
}
let weather = new WeatherObject();
// get hourly weather, if requested
const hours = [];
if (data.hasOwnProperty("hourly")) {
for (const hour of data.hourly) {
weather.date = moment.unix(hour.dt).utcOffset(data.timezone_offset / 60);
weather.temperature = hour.temp;
weather.feelsLikeTemp = hour.feels_like;
weather.humidity = hour.humidity;
weather.windSpeed = hour.wind_speed;
weather.windFromDirection = hour.wind_deg;
weather.weatherType = this.convertWeatherType(hour.weather[0].icon);
weather.precipitationProbability = hour.pop ? hour.pop * 100 : undefined;
precip = false;
if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) {
weather.rain = hour.rain["1h"];
precip = true;
}
if (hour.hasOwnProperty("snow") && !isNaN(hour.snow["1h"])) {
weather.snow = hour.snow["1h"];
precip = true;
}
if (precip) {
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
}
hours.push(weather);
weather = new WeatherObject();
}
}
// get daily weather, if requested
const days = [];
if (data.hasOwnProperty("daily")) {
for (const day of data.daily) {
weather.date = moment.unix(day.dt).utcOffset(data.timezone_offset / 60);
weather.sunrise = moment.unix(day.sunrise).utcOffset(data.timezone_offset / 60);
weather.sunset = moment.unix(day.sunset).utcOffset(data.timezone_offset / 60);
weather.minTemperature = day.temp.min;
weather.maxTemperature = day.temp.max;
weather.humidity = day.humidity;
weather.windSpeed = day.wind_speed;
weather.windFromDirection = day.wind_deg;
weather.weatherType = this.convertWeatherType(day.weather[0].icon);
weather.precipitationProbability = day.pop ? day.pop * 100 : undefined;
precip = false;
if (!isNaN(day.rain)) {
weather.rain = day.rain;
precip = true;
}
if (!isNaN(day.snow)) {
weather.snow = day.snow;
precip = true;
}
if (precip) {
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
}
days.push(weather);
weather = new WeatherObject();
}
}
return { current: current, hours: hours, days: days };
},
/*
* Convert the OpenWeatherMap icons to a more usable name.
*/
convertWeatherType(weatherType) {
const weatherTypes = {
"01d": "day-sunny",
"02d": "day-cloudy",
"03d": "cloudy",
"04d": "cloudy-windy",
"09d": "showers",
"10d": "rain",
"11d": "thunderstorm",
"13d": "snow",
"50d": "fog",
"01n": "night-clear",
"02n": "night-cloudy",
"03n": "night-cloudy",
"04n": "night-cloudy",
"09n": "night-showers",
"10n": "night-rain",
"11n": "night-thunderstorm",
"13n": "night-snow",
"50n": "night-alt-cloudy-windy"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
},
/* getParams(compliments)
* Generates an url with api parameters based on the config.
*
* return String - URL params.
*/
getParams() {
let params = "?";
if (this.config.weatherEndpoint === "/onecall") {
params += `lat=${this.config.lat}`;
params += `&lon=${this.config.lon}`;
if (this.config.type === "current") {
params += "&exclude=minutely,hourly,daily";
} else if (this.config.type === "hourly") {
params += "&exclude=current,minutely,daily";
} else if (this.config.type === "daily" || this.config.type === "forecast") {
params += "&exclude=current,minutely,hourly";
} else {
params += "&exclude=minutely";
}
} else if (this.config.lat && this.config.lon) {
params += `lat=${this.config.lat}&lon=${this.config.lon}`;
} else if (this.config.locationID) {
params += `id=${this.config.locationID}`;
} else if (this.config.location) {
params += `q=${this.config.location}`;
} else if (this.firstEvent && this.firstEvent.geo) {
params += `lat=${this.firstEvent.geo.lat}&lon=${this.firstEvent.geo.lon}`;
} else if (this.firstEvent && this.firstEvent.location) {
params += `q=${this.firstEvent.location}`;
} else {
// TODO hide doesnt exist!
this.hide(this.config.animationSpeed, { lockString: this.identifier });
return;
}
params += "&units=metric"; // WeatherProviders should use metric internally and use the units only for when displaying data
params += `&lang=${this.config.lang}`;
params += `&APPID=${this.config.apiKey}`;
return params;
}
});

@ -0,0 +1,133 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
* Provider: Pirate Weather
*
* Written by Nicholas Hubbard https://github.com/nhubbard for formerly Dark Sky Provider
* Modified by Karsten Hassel for Pirate Weather
* MIT Licensed
*
* This class is a provider for Pirate Weather, it is a replacement for Dark Sky (same api).
*/
WeatherProvider.register("pirateweather", {
// Set the name of the provider.
// Not strictly required, but helps for debugging.
providerName: "pirateweather",
// Set the default config properties that is specific to this provider
defaults: {
useCorsProxy: true,
apiBase: "https://api.pirateweather.net",
weatherEndpoint: "/forecast",
apiKey: "",
lat: 0,
lon: 0
},
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => {
if (!data || !data.currently || typeof data.currently.temperature === "undefined") {
// No usable data?
return;
}
const currentWeather = this.generateWeatherDayFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
fetchWeatherForecast() {
this.fetchData(this.getUrl())
.then((data) => {
if (!data || !data.daily || !data.daily.data.length) {
// No usable data?
return;
}
const forecast = this.generateWeatherObjectsFromForecast(data.daily.data);
this.setWeatherForecast(forecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
// Create a URL from the config and base URL.
getUrl() {
return `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${this.config.lang}`;
},
// Implement WeatherDay generator.
generateWeatherDayFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject();
currentWeather.date = moment();
currentWeather.humidity = parseFloat(currentWeatherData.currently.humidity);
currentWeather.temperature = parseFloat(currentWeatherData.currently.temperature);
currentWeather.windSpeed = parseFloat(currentWeatherData.currently.windSpeed);
currentWeather.windFromDirection = currentWeatherData.currently.windBearing;
currentWeather.weatherType = this.convertWeatherType(currentWeatherData.currently.icon);
currentWeather.sunrise = moment.unix(currentWeatherData.daily.data[0].sunriseTime);
currentWeather.sunset = moment.unix(currentWeatherData.daily.data[0].sunsetTime);
return currentWeather;
},
generateWeatherObjectsFromForecast(forecasts) {
const days = [];
for (const forecast of forecasts) {
const weather = new WeatherObject();
weather.date = moment.unix(forecast.time);
weather.minTemperature = forecast.temperatureMin;
weather.maxTemperature = forecast.temperatureMax;
weather.weatherType = this.convertWeatherType(forecast.icon);
weather.snow = 0;
weather.rain = 0;
let precip = 0;
if (forecast.hasOwnProperty("precipAccumulation")) {
precip = forecast.precipAccumulation * 10;
}
weather.precipitationAmount = precip;
if (forecast.hasOwnProperty("precipType")) {
if (forecast.precipType === "snow") {
weather.snow = precip;
} else {
weather.rain = precip;
}
}
days.push(weather);
}
return days;
},
// Map icons from Pirate Weather to our icons.
convertWeatherType(weatherType) {
const weatherTypes = {
"clear-day": "day-sunny",
"clear-night": "night-clear",
rain: "rain",
snow: "snow",
sleet: "snow",
wind: "wind",
fog: "fog",
cloudy: "cloudy",
"partly-cloudy-day": "day-cloudy",
"partly-cloudy-night": "night-cloudy"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
}
});

@ -0,0 +1,334 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
* Provider: SMHI
*
* By BuXXi https://github.com/buxxi
* MIT Licensed
*
* This class is a provider for SMHI (Sweden only). Metric system is the only
* supported unit.
*/
WeatherProvider.register("smhi", {
providerName: "SMHI",
// Set the default config properties that is specific to this provider
defaults: {
lat: 0, // Cant have more than 6 digits
lon: 0, // Cant have more than 6 digits
precipitationValue: "pmedian",
location: false
},
/**
* Implements method in interface for fetching current weather.
*/
fetchCurrentWeather() {
this.fetchData(this.getURL())
.then((data) => {
const closest = this.getClosestToCurrentTime(data.timeSeries);
const coordinates = this.resolveCoordinates(data);
const weatherObject = this.convertWeatherDataToObject(closest, coordinates);
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
this.setCurrentWeather(weatherObject);
})
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.finally(() => this.updateAvailable());
},
/**
* Implements method in interface for fetching a multi-day forecast.
*/
fetchWeatherForecast() {
this.fetchData(this.getURL())
.then((data) => {
const coordinates = this.resolveCoordinates(data);
const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates);
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
this.setWeatherForecast(weatherObjects);
})
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.finally(() => this.updateAvailable());
},
/**
* Implements method in interface for fetching hourly forecasts.
*/
fetchWeatherHourly() {
this.fetchData(this.getURL())
.then((data) => {
const coordinates = this.resolveCoordinates(data);
const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates, "hour");
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
this.setWeatherHourly(weatherObjects);
})
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.finally(() => this.updateAvailable());
},
/**
* Overrides method for setting config with checks for the precipitationValue being unset or invalid
* @param {object} config The configuration object
*/
setConfig(config) {
this.config = config;
if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) {
Log.log(`invalid or not set: ${config.precipitationValue}`);
config.precipitationValue = this.defaults.precipitationValue;
}
},
/**
* Of all the times returned find out which one is closest to the current time, should be the first if the data isn't old.
* @param {object[]} times Array of time objects
* @returns {object} The weatherdata closest to the current time
*/
getClosestToCurrentTime(times) {
let now = moment();
let minDiff = undefined;
for (const time of times) {
let diff = Math.abs(moment(time.validTime).diff(now));
if (!minDiff || diff < Math.abs(moment(minDiff.validTime).diff(now))) {
minDiff = time;
}
}
return minDiff;
},
/**
* Get the forecast url for the configured coordinates
* @returns {string} the url for the specified coordinates
*/
getURL() {
const formatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 6,
maximumFractionDigits: 6
});
const lon = formatter.format(this.config.lon);
const lat = formatter.format(this.config.lat);
return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`;
},
/**
* Calculates the apparent temperature based on known atmospheric data.
* @param {object} weatherData Weatherdata to use for the calculation
* @returns {number} The apparent temperature
*/
calculateApparentTemperature(weatherData) {
const Ta = this.paramValue(weatherData, "t");
const rh = this.paramValue(weatherData, "r");
const ws = this.paramValue(weatherData, "ws");
const p = (rh / 100) * 6.105 * Math.E * ((17.27 * Ta) / (237.7 + Ta));
return Ta + 0.33 * p - 0.7 * ws - 4;
},
/**
* Converts the returned data into a WeatherObject with required properties set for both current weather and forecast.
* The returned units is always in metric system.
* Requires coordinates to determine if its daytime or nighttime to know which icon to use and also to set sunrise and sunset.
* @param {object} weatherData Weatherdata to convert
* @param {object} coordinates Coordinates of the locations of the weather
* @returns {WeatherObject} The converted weatherdata at the specified location
*/
convertWeatherDataToObject(weatherData, coordinates) {
let currentWeather = new WeatherObject();
currentWeather.date = moment(weatherData.validTime);
currentWeather.updateSunTime(coordinates.lat, coordinates.lon);
currentWeather.humidity = this.paramValue(weatherData, "r");
currentWeather.temperature = this.paramValue(weatherData, "t");
currentWeather.windSpeed = this.paramValue(weatherData, "ws");
currentWeather.windFromDirection = this.paramValue(weatherData, "wd");
currentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, "Wsymb2"), currentWeather.isDayTime());
currentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData);
// Determine the precipitation amount and category and update the
// weatherObject with it, the valuetype to use can be configured or uses
// median as default.
let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue);
switch (this.paramValue(weatherData, "pcat")) {
// 0 = No precipitation
case 1: // Snow
currentWeather.snow += precipitationValue;
currentWeather.precipitationAmount += precipitationValue;
break;
case 2: // Snow and rain, treat it as 50/50 snow and rain
currentWeather.snow += precipitationValue / 2;
currentWeather.rain += precipitationValue / 2;
currentWeather.precipitationAmount += precipitationValue;
break;
case 3: // Rain
case 4: // Drizzle
case 5: // Freezing rain
case 6: // Freezing drizzle
currentWeather.rain += precipitationValue;
currentWeather.precipitationAmount += precipitationValue;
break;
}
return currentWeather;
},
/**
* Takes all the data points and converts it to one WeatherObject per day.
* @param {object[]} allWeatherData Array of weatherdata
* @param {object} coordinates Coordinates of the locations of the weather
* @param {string} groupBy The interval to use for grouping the data (day, hour)
* @returns {WeatherObject[]} Array of weatherobjects
*/
convertWeatherDataGroupedBy(allWeatherData, coordinates, groupBy = "day") {
let currentWeather;
let result = [];
let allWeatherObjects = this.fillInGaps(allWeatherData).map((weatherData) => this.convertWeatherDataToObject(weatherData, coordinates));
let dayWeatherTypes = [];
for (const weatherObject of allWeatherObjects) {
//If its the first object or if a day/hour change we need to reset the summary object
if (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) {
currentWeather = new WeatherObject();
dayWeatherTypes = [];
currentWeather.temperature = weatherObject.temperature;
currentWeather.date = weatherObject.date;
currentWeather.minTemperature = Infinity;
currentWeather.maxTemperature = -Infinity;
currentWeather.snow = 0;
currentWeather.rain = 0;
currentWeather.precipitationAmount = 0;
result.push(currentWeather);
}
//Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast
if (weatherObject.isDayTime()) {
dayWeatherTypes.push(weatherObject.weatherType);
}
if (dayWeatherTypes.length > 0) {
currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)];
} else {
currentWeather.weatherType = weatherObject.weatherType;
}
//All other properties is either a sum, min or max of each hour
currentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature);
currentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature);
currentWeather.snow += weatherObject.snow;
currentWeather.rain += weatherObject.rain;
currentWeather.precipitationAmount += weatherObject.precipitationAmount;
}
return result;
},
/**
* Resolve coordinates from the response data (probably preferably to use
* this if it's not matching the config values exactly)
* @param {object} data Response data from the weather service
* @returns {{lon, lat}} the lat/long coordinates of the data
*/
resolveCoordinates(data) {
return { lat: data.geometry.coordinates[0][1], lon: data.geometry.coordinates[0][0] };
},
/**
* The distance between the data points is increasing in the data the more distant the prediction is.
* Find these gaps and fill them with the previous hours data to make the data returned a complete set.
* @param {object[]} data Response data from the weather service
* @returns {object[]} Given data with filled gaps
*/
fillInGaps(data) {
let result = [];
for (let i = 1; i < data.length; i++) {
let to = moment(data[i].validTime);
let from = moment(data[i - 1].validTime);
let hours = moment.duration(to.diff(from)).asHours();
// For each hour add a datapoint but change the validTime
for (let j = 0; j < hours; j++) {
let current = Object.assign({}, data[i]);
current.validTime = from.clone().add(j, "hours").toISOString();
result.push(current);
}
}
return result;
},
/**
* Helper method to get a property from the returned data set.
* @param {object} currentWeatherData Weatherdata to get from
* @param {string} name The name of the property
* @returns {*} The value of the property in the weatherdata
*/
paramValue(currentWeatherData, name) {
return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0];
},
/**
* Map the icon value from SMHI to an icon that MagicMirror² understands.
* Uses different icons depending on if its daytime or nighttime.
* SMHI's description of what the numeric value means is the comment after the case.
* @param {number} input The SMHI icon value
* @param {boolean} isDayTime True if the icon should be for daytime, false for nighttime
* @returns {string} The icon name for the MagicMirror
*/
convertWeatherType(input, isDayTime) {
switch (input) {
case 1:
return isDayTime ? "day-sunny" : "night-clear"; // Clear sky
case 2:
return isDayTime ? "day-sunny-overcast" : "night-partly-cloudy"; // Nearly clear sky
case 3:
return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable cloudiness
case 4:
return isDayTime ? "day-cloudy" : "night-cloudy"; // Halfclear sky
case 5:
return "cloudy"; // Cloudy sky
case 6:
return "cloudy"; // Overcast
case 7:
return "fog"; // Fog
case 8:
return "showers"; // Light rain showers
case 9:
return "showers"; // Moderate rain showers
case 10:
return "showers"; // Heavy rain showers
case 11:
return "thunderstorm"; // Thunderstorm
case 12:
return "sleet"; // Light sleet showers
case 13:
return "sleet"; // Moderate sleet showers
case 14:
return "sleet"; // Heavy sleet showers
case 15:
return "snow"; // Light snow showers
case 16:
return "snow"; // Moderate snow showers
case 17:
return "snow"; // Heavy snow showers
case 18:
return "rain"; // Light rain
case 19:
return "rain"; // Moderate rain
case 20:
return "rain"; // Heavy rain
case 21:
return "thunderstorm"; // Thunder
case 22:
return "sleet"; // Light sleet
case 23:
return "sleet"; // Moderate sleet
case 24:
return "sleet"; // Heavy sleet
case 25:
return "snow"; // Light snowfall
case 26:
return "snow"; // Moderate snowfall
case 27:
return "snow"; // Heavy snowfall
default:
return "";
}
}
});

@ -0,0 +1,201 @@
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
*
* By Malcolm Oakes https://github.com/maloakes
* MIT Licensed.
*
* This class is a provider for UK Met Office Datapoint.
*/
WeatherProvider.register("ukmetoffice", {
// Set the name of the provider.
// This isn't strictly necessary, since it will fallback to the provider identifier
// But for debugging (and future alerts) it would be nice to have the real name.
providerName: "UK Met Office",
// Set the default config properties that is specific to this provider
defaults: {
apiBase: "http://datapoint.metoffice.gov.uk/public/data/val/wxfcs/all/json/",
locationID: false,
apiKey: ""
},
// Overwrite the fetchCurrentWeather method.
fetchCurrentWeather() {
this.fetchData(this.getUrl("3hourly"))
.then((data) => {
if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) {
// Did not receive usable new data.
// Maybe this needs a better check?
return;
}
this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`);
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
// Overwrite the fetchCurrentWeather method.
fetchWeatherForecast() {
this.fetchData(this.getUrl("daily"))
.then((data) => {
if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) {
// Did not receive usable new data.
// Maybe this needs a better check?
return;
}
this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`);
const forecast = this.generateWeatherObjectsFromForecast(data);
this.setWeatherForecast(forecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
/** UK Met Office Specific Methods - These are not part of the default provider methods */
/*
* Gets the complete url for the request
*/
getUrl(forecastType) {
return this.config.apiBase + this.config.locationID + this.getParams(forecastType);
},
/*
* Generate a WeatherObject based on currentWeatherInformation
*/
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject();
const location = currentWeatherData.SiteRep.DV.Location;
// data times are always UTC
let nowUtc = moment.utc();
let midnightUtc = nowUtc.clone().startOf("day");
let timeInMins = nowUtc.diff(midnightUtc, "minutes");
// loop round each of the (5) periods, look for today (the first period may be yesterday)
for (const period of location.Period) {
const periodDate = moment.utc(period.value.substr(0, 10), "YYYY-MM-DD");
// ignore if period is before today
if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) {
// check this is the period we want, after today the diff will be -ve
if (moment().diff(periodDate, "minutes") > 0) {
// loop round the reports looking for the one we are in
// $ value specifies the time in minutes-of-the-day: 0, 180, 360,...1260
for (const rep of period.Rep) {
const p = rep.$;
if (timeInMins >= p && timeInMins - 180 < p) {
// finally got the one we want, so populate weather object
currentWeather.humidity = rep.H;
currentWeather.temperature = rep.T;
currentWeather.feelsLikeTemp = rep.F;
currentWeather.precipitationProbability = parseInt(rep.Pp);
currentWeather.windSpeed = WeatherUtils.convertWindToMetric(rep.S);
currentWeather.windFromDirection = WeatherUtils.convertWindDirection(rep.D);
currentWeather.weatherType = this.convertWeatherType(rep.W);
}
}
}
}
}
// determine the sunrise/sunset times - not supplied in UK Met Office data
currentWeather.updateSunTime(location.lat, location.lon);
return currentWeather;
},
/*
* Generate WeatherObjects based on forecast information
*/
generateWeatherObjectsFromForecast(forecasts) {
const days = [];
// 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();
// data times are always UTC
const dateStr = period.value;
let periodDate = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD");
// ignore if period is before today
if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) {
// populate the weather object
weather.date = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD");
weather.minTemperature = period.Rep[1].Nm;
weather.maxTemperature = period.Rep[0].Dm;
weather.weatherType = this.convertWeatherType(period.Rep[0].W);
weather.precipitationProbability = parseInt(period.Rep[0].PPd);
days.push(weather);
}
}
return days;
},
/*
* Convert the Met Office icons to a more usable name.
*/
convertWeatherType(weatherType) {
const weatherTypes = {
0: "night-clear",
1: "day-sunny",
2: "night-alt-cloudy",
3: "day-cloudy",
5: "fog",
6: "fog",
7: "cloudy",
8: "cloud",
9: "night-sprinkle",
10: "day-sprinkle",
11: "raindrops",
12: "sprinkle",
13: "night-alt-showers",
14: "day-showers",
15: "rain",
16: "night-alt-sleet",
17: "day-sleet",
18: "sleet",
19: "night-alt-hail",
20: "day-hail",
21: "hail",
22: "night-alt-snow",
23: "day-snow",
24: "snow",
25: "night-alt-snow",
26: "day-snow",
27: "snow",
28: "night-alt-thunderstorm",
29: "day-thunderstorm",
30: "thunderstorm"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
},
/**
* Generates an url with api parameters based on the config.
* @param {string} forecastType daily or 3hourly forecast
* @returns {string} url
*/
getParams(forecastType) {
let params = "?";
params += `res=${forecastType}`;
params += `&key=${this.config.apiKey}`;
return params;
}
});

@ -0,0 +1,271 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
*
* By Malcolm Oakes https://github.com/maloakes
* Existing Met Office provider edited for new MetOffice Data Hub by CreepinJesus http://github.com/XBCreepinJesus
* MIT Licensed.
*
* This class is a provider for UK Met Office Data Hub (the replacement for their Data Point services).
* For more information on Data Hub, see https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub
* Data available:
* Hourly data for next 2 days ("hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-hourly.pdf
* 3-hourly data for the next 7 days ("3hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-3-hourly.pdf
* Daily data for the next 7 days ("daily") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-daily.pdf
*
* NOTES
* This provider requires longitude/latitude coordinates, rather than a location ID (as with the previous Met Office provider)
* Provide the following in your config.js file:
* weatherProvider: "ukmetofficedatahub",
* apiBase: "https://api-metoffice.apiconnect.ibmcloud.com/metoffice/production/v0/forecasts/point/",
* apiKey: "[YOUR API KEY]",
* apiSecret: "[YOUR API SECRET]",
* lat: [LATITUDE (DECIMAL)],
* lon: [LONGITUDE (DECIMAL)]
*
* At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when
* setting your update intervals. For reference, 360 requests per day is once every 4 minutes.
*
* Pay attention to the units of the supplied data from the Met Office - it is given in SI/metric units where applicable:
* - Temperatures are in degrees Celsius (°C)
* - Wind speeds are in metres per second (m/s)
* - Wind direction given in degrees (°)
* - Pressures are in Pascals (Pa)
* - Distances are in metres (m)
* - Probabilities and humidity are given as percentages (%)
* - Precipitation is measured in millimetres (mm) with rates per hour (mm/h)
*
* See the PDFs linked above for more information on the data their corresponding units.
*/
WeatherProvider.register("ukmetofficedatahub", {
// Set the name of the provider.
providerName: "UK Met Office (DataHub)",
// Set the default config properties that is specific to this provider
defaults: {
apiBase: "https://api-metoffice.apiconnect.ibmcloud.com/metoffice/production/v0/forecasts/point/",
apiKey: "",
apiSecret: "",
lat: 0,
lon: 0
},
// Build URL with query strings according to DataHub API (https://metoffice.apiconnect.ibmcloud.com/metoffice/production/api)
getUrl(forecastType) {
let queryStrings = "?";
queryStrings += `latitude=${this.config.lat}`;
queryStrings += `&longitude=${this.config.lon}`;
queryStrings += `&includeLocationName=${true}`;
// Return URL, making sure there is a trailing "/" in the base URL.
return this.config.apiBase + (this.config.apiBase.endsWith("/") ? "" : "/") + forecastType + queryStrings;
},
// Build the list of headers for the request
// For DataHub requests, the API key/secret are sent in the headers rather than as query strings.
// Headers defined according to Data Hub API (https://metoffice.apiconnect.ibmcloud.com/metoffice/production/api)
getHeaders() {
return {
accept: "application/json",
"x-ibm-client-id": this.config.apiKey,
"x-ibm-client-secret": this.config.apiSecret
};
},
// Fetch data using supplied URL and request headers
async fetchWeather(url, headers) {
const response = await fetch(url, { headers: headers });
// Return JSON data
return response.json();
},
// Fetch hourly forecast data (to use for current weather)
fetchCurrentWeather() {
this.fetchWeather(this.getUrl("hourly"), this.getHeaders())
.then((data) => {
// Check data is usable
if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {
// Did not receive usable new data.
// Maybe this needs a better check?
Log.error("Possibly bad current/hourly data?");
Log.error(data);
return;
}
// Set location name
this.setFetchedLocation(`${data.features[0].properties.location.name}`);
// Generate current weather data
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
// Catch any error(s)
.catch((error) => Log.error(`Could not load data: ${error.message}`))
// Let the module know there is data available
.finally(() => this.updateAvailable());
},
// Create a WeatherObject using current weather data (data for the current hour)
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject();
// Extract the actual forecasts
let forecastDataHours = currentWeatherData.features[0].properties.timeSeries;
// Define now
let nowUtc = moment.utc();
// Find hour that contains the current time
for (let hour in forecastDataHours) {
let forecastTime = moment.utc(forecastDataHours[hour].time);
if (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, "h")))) {
currentWeather.date = forecastTime;
currentWeather.windSpeed = forecastDataHours[hour].windSpeed10m;
currentWeather.windFromDirection = forecastDataHours[hour].windDirectionFrom10m;
currentWeather.temperature = forecastDataHours[hour].screenTemperature;
currentWeather.minTemperature = forecastDataHours[hour].minScreenAirTemp;
currentWeather.maxTemperature = forecastDataHours[hour].maxScreenAirTemp;
currentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode);
currentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity;
currentWeather.rain = forecastDataHours[hour].totalPrecipAmount;
currentWeather.snow = forecastDataHours[hour].totalSnowAmount;
currentWeather.precipitationProbability = forecastDataHours[hour].probOfPrecipitation;
currentWeather.feelsLikeTemp = forecastDataHours[hour].feelsLikeTemperature;
// Pass on full details, so they can be used in custom templates
// Note the units of the supplied data when using this (see top of file)
currentWeather.rawData = forecastDataHours[hour];
}
}
// Determine the sunrise/sunset times - (still) not supplied in UK Met Office data
// Passes {longitude, latitude} to SunCalc, could pass height to, but
// SunCalc.getTimes doesn't take that into account
currentWeather.updateSunTime(this.config.lat, this.config.lon);
return currentWeather;
},
// Fetch daily forecast data
fetchWeatherForecast() {
this.fetchWeather(this.getUrl("daily"), this.getHeaders())
.then((data) => {
// Check data is usable
if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {
// Did not receive usable new data.
// Maybe this needs a better check?
Log.error("Possibly bad forecast data?");
Log.error(data);
return;
}
// Set location name
this.setFetchedLocation(`${data.features[0].properties.location.name}`);
// Generate the forecast data
const forecast = this.generateWeatherObjectsFromForecast(data);
this.setWeatherForecast(forecast);
})
// Catch any error(s)
.catch((error) => Log.error(`Could not load data: ${error.message}`))
// Let the module know there is new data available
.finally(() => this.updateAvailable());
},
// Create a WeatherObject for each day using daily forecast data
generateWeatherObjectsFromForecast(forecasts) {
const dailyForecasts = [];
// Extract the actual forecasts
let forecastDataDays = forecasts.features[0].properties.timeSeries;
// Define today
let today = moment.utc().startOf("date");
// Go through each day in the forecasts
for (let day in forecastDataDays) {
const forecastWeather = new WeatherObject();
// Get date of forecast
let forecastDate = moment.utc(forecastDataDays[day].time);
// Check if forecast is for today or in the future (i.e., ignore yesterday's forecast)
if (forecastDate.isSameOrAfter(today)) {
forecastWeather.date = forecastDate;
forecastWeather.minTemperature = forecastDataDays[day].nightMinScreenTemperature;
forecastWeather.maxTemperature = forecastDataDays[day].dayMaxScreenTemperature;
// Using daytime forecast values
forecastWeather.windSpeed = forecastDataDays[day].midday10MWindSpeed;
forecastWeather.windFromDirection = forecastDataDays[day].midday10MWindDirection;
forecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode);
forecastWeather.precipitationProbability = forecastDataDays[day].dayProbabilityOfPrecipitation;
forecastWeather.temperature = forecastDataDays[day].dayMaxScreenTemperature;
forecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity;
forecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain;
forecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow;
forecastWeather.feelsLikeTemp = forecastDataDays[day].dayMaxFeelsLikeTemp;
// Pass on full details, so they can be used in custom templates
// Note the units of the supplied data when using this (see top of file)
forecastWeather.rawData = forecastDataDays[day];
dailyForecasts.push(forecastWeather);
}
}
return dailyForecasts;
},
// Set the fetched location name.
setFetchedLocation: function (name) {
this.fetchedLocationName = name;
},
// Match the Met Office "significant weather code" to a weathericons.css icon
// Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264
// and: https://erikflowers.github.io/weather-icons/
convertWeatherType(weatherType) {
const weatherTypes = {
0: "night-clear",
1: "day-sunny",
2: "night-alt-cloudy",
3: "day-cloudy",
5: "fog",
6: "fog",
7: "cloudy",
8: "cloud",
9: "night-sprinkle",
10: "day-sprinkle",
11: "raindrops",
12: "sprinkle",
13: "night-alt-showers",
14: "day-showers",
15: "rain",
16: "night-alt-sleet",
17: "day-sleet",
18: "sleet",
19: "night-alt-hail",
20: "day-hail",
21: "hail",
22: "night-alt-snow",
23: "day-snow",
24: "snow",
25: "night-alt-snow",
26: "day-snow",
27: "snow",
28: "night-alt-thunderstorm",
29: "day-thunderstorm",
30: "thunderstorm"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
}
});

@ -0,0 +1,208 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
* Provider: Weatherbit
*
* By Andrew Pometti
* MIT Licensed
*
* This class is a provider for Weatherbit, based on Nicholas Hubbard's class
* for Dark Sky & Vince Peri's class for Weather.gov.
*/
WeatherProvider.register("weatherbit", {
// Set the name of the provider.
// Not strictly required, but helps for debugging.
providerName: "Weatherbit",
// Set the default config properties that is specific to this provider
defaults: {
apiBase: "https://api.weatherbit.io/v2.0",
apiKey: "",
lat: 0,
lon: 0
},
fetchedLocation: function () {
return this.fetchedLocationName || "";
},
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => {
if (!data || !data.data[0] || typeof data.data[0].temp === "undefined") {
// No usable data?
return;
}
const currentWeather = this.generateWeatherDayFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
fetchWeatherForecast() {
this.fetchData(this.getUrl())
.then((data) => {
if (!data || !data.data) {
// No usable data?
return;
}
const forecast = this.generateWeatherObjectsFromForecast(data.data);
this.setWeatherForecast(forecast);
this.fetchedLocationName = `${data.city_name}, ${data.state_code}`;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
/**
* Overrides method for setting config to check if endpoint is correct for hourly
* @param {object} config The configuration object
*/
setConfig(config) {
this.config = config;
if (!this.config.weatherEndpoint) {
switch (this.config.type) {
case "hourly":
this.config.weatherEndpoint = "/forecast/hourly";
break;
case "daily":
case "forecast":
this.config.weatherEndpoint = "/forecast/daily";
break;
case "current":
this.config.weatherEndpoint = "/current";
break;
default:
Log.error("weatherEndpoint not configured and could not resolve it based on type");
}
}
},
// Create a URL from the config and base URL.
getUrl() {
return `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`;
},
// Implement WeatherDay generator.
generateWeatherDayFromCurrentWeather(currentWeatherData) {
//Calculate TZ Offset and invert to convert Sunrise/Sunset times to Local
const d = new Date();
let tzOffset = d.getTimezoneOffset();
tzOffset = tzOffset * -1;
const currentWeather = new WeatherObject();
currentWeather.date = moment.unix(currentWeatherData.data[0].ts);
currentWeather.humidity = parseFloat(currentWeatherData.data[0].rh);
currentWeather.temperature = parseFloat(currentWeatherData.data[0].temp);
currentWeather.windSpeed = parseFloat(currentWeatherData.data[0].wind_spd);
currentWeather.windFromDirection = currentWeatherData.data[0].wind_dir;
currentWeather.weatherType = this.convertWeatherType(currentWeatherData.data[0].weather.icon);
currentWeather.sunrise = moment(currentWeatherData.data[0].sunrise, "HH:mm").add(tzOffset, "m");
currentWeather.sunset = moment(currentWeatherData.data[0].sunset, "HH:mm").add(tzOffset, "m");
this.fetchedLocationName = `${currentWeatherData.data[0].city_name}, ${currentWeatherData.data[0].state_code}`;
return currentWeather;
},
generateWeatherObjectsFromForecast(forecasts) {
const days = [];
for (const forecast of forecasts) {
const weather = new WeatherObject();
weather.date = moment(forecast.datetime, "YYYY-MM-DD");
weather.minTemperature = forecast.min_temp;
weather.maxTemperature = forecast.max_temp;
weather.precipitationAmount = forecast.precip;
weather.precipitationProbability = forecast.pop;
weather.weatherType = this.convertWeatherType(forecast.weather.icon);
days.push(weather);
}
return days;
},
// Map icons from Dark Sky to our icons.
convertWeatherType(weatherType) {
const weatherTypes = {
t01d: "day-thunderstorm",
t01n: "night-alt-thunderstorm",
t02d: "day-thunderstorm",
t02n: "night-alt-thunderstorm",
t03d: "thunderstorm",
t03n: "thunderstorm",
t04d: "day-thunderstorm",
t04n: "night-alt-thunderstorm",
t05d: "day-sleet-storm",
t05n: "night-alt-sleet-storm",
d01d: "day-sprinkle",
d01n: "night-alt-sprinkle",
d02d: "day-sprinkle",
d02n: "night-alt-sprinkle",
d03d: "day-shower",
d03n: "night-alt-shower",
r01d: "day-shower",
r01n: "night-alt-shower",
r02d: "day-rain",
r02n: "night-alt-rain",
r03d: "day-rain",
r03n: "night-alt-rain",
r04d: "day-sprinkle",
r04n: "night-alt-sprinkle",
r05d: "day-shower",
r05n: "night-alt-shower",
r06d: "day-shower",
r06n: "night-alt-shower",
f01d: "day-sleet",
f01n: "night-alt-sleet",
s01d: "day-snow",
s01n: "night-alt-snow",
s02d: "day-snow-wind",
s02n: "night-alt-snow-wind",
s03d: "snowflake-cold",
s03n: "snowflake-cold",
s04d: "day-rain-mix",
s04n: "night-alt-rain-mix",
s05d: "day-sleet",
s05n: "night-alt-sleet",
s06d: "day-snow",
s06n: "night-alt-snow",
a01d: "day-haze",
a01n: "dust",
a02d: "smoke",
a02n: "smoke",
a03d: "day-haze",
a03n: "dust",
a04d: "dust",
a04n: "dust",
a05d: "day-fog",
a05n: "night-fog",
a06d: "fog",
a06n: "fog",
c01d: "day-sunny",
c01n: "night-clear",
c02d: "day-sunny-overcast",
c02n: "night-alt-partly-cloudy",
c03d: "day-cloudy",
c03n: "night-alt-cloudy",
c04d: "cloudy",
c04n: "cloudy",
u00d: "rain-mix",
u00n: "rain-mix"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
}
});

@ -0,0 +1,77 @@
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
* Provider: Weatherflow
*
* By Tobias Dreyem https://github.com/10bias
* MIT Licensed
*
* This class is a provider for Weatherflow.
* Note that the Weatherflow API does not provide snowfall.
*/
WeatherProvider.register("weatherflow", {
// Set the name of the provider.
// Not strictly required, but helps for debugging
providerName: "WeatherFlow",
// Set the default config properties that is specific to this provider
defaults: {
apiBase: "https://swd.weatherflow.com/swd/rest/",
token: "",
stationid: ""
},
fetchCurrentWeather() {
this.fetchData(this.getUrl())
.then((data) => {
const currentWeather = new WeatherObject();
currentWeather.date = moment();
currentWeather.humidity = data.current_conditions.relative_humidity;
currentWeather.temperature = data.current_conditions.air_temperature;
currentWeather.windSpeed = WeatherUtils.convertWindToMs(data.current_conditions.wind_avg);
currentWeather.windFromDirection = data.current_conditions.wind_direction;
currentWeather.weatherType = data.forecast.daily[0].icon;
currentWeather.sunrise = moment.unix(data.forecast.daily[0].sunrise);
currentWeather.sunset = moment.unix(data.forecast.daily[0].sunset);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
fetchWeatherForecast() {
this.fetchData(this.getUrl())
.then((data) => {
const days = [];
for (const forecast of data.forecast.daily) {
const weather = new WeatherObject();
weather.date = moment.unix(forecast.day_start_local);
weather.minTemperature = forecast.air_temp_low;
weather.maxTemperature = forecast.air_temp_high;
weather.precipitationProbability = forecast.precip_probability;
weather.weatherType = forecast.icon;
weather.snow = 0;
days.push(weather);
}
this.setWeatherForecast(days);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
// Create a URL from the config and base URL.
getUrl() {
return `${this.config.apiBase}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`;
}
});

@ -0,0 +1,367 @@
/* global WeatherProvider, WeatherObject, WeatherUtils */
/* MagicMirror²
* Module: Weather
* Provider: weather.gov
* https://weather-gov.github.io/api/general-faqs
*
* Original by Vince Peri
* MIT Licensed.
*
* This class is a provider for weather.gov.
* Note that this is only for US locations (lat and lon) and does not require an API key
* Since it is free, there are some items missing - like sunrise, sunset
*/
WeatherProvider.register("weathergov", {
// Set the name of the provider.
// This isn't strictly necessary, since it will fallback to the provider identifier
// But for debugging (and future alerts) it would be nice to have the real name.
providerName: "Weather.gov",
// Set the default config properties that is specific to this provider
defaults: {
apiBase: "https://api.weather.gov/points/",
lat: 0,
lon: 0
},
// Flag all needed URLs availability
configURLs: false,
//This API has multiple urls involved
forecastURL: "tbd",
forecastHourlyURL: "tbd",
forecastGridDataURL: "tbd",
observationStationsURL: "tbd",
stationObsURL: "tbd",
// Called to set the config, this config is the same as the weather module's config.
setConfig: function (config) {
this.config = config;
this.config.apiBase = "https://api.weather.gov";
this.fetchWxGovURLs(this.config);
},
// Called when the weather provider is about to start.
start: function () {
Log.info(`Weather provider: ${this.providerName} started.`);
},
// This returns the name of the fetched location or an empty string.
fetchedLocation: function () {
return this.fetchedLocationName || "";
},
// Overwrite the fetchCurrentWeather method.
fetchCurrentWeather() {
if (!this.configURLs) {
Log.info("fetchCurrentWeather: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.stationObsURL)
.then((data) => {
if (!data || !data.properties) {
// Did not receive usable new data.
return;
}
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data.properties);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load station obs data ... ", request);
})
.finally(() => this.updateAvailable());
},
// Overwrite the fetchWeatherForecast method.
fetchWeatherForecast() {
if (!this.configURLs) {
Log.info("fetchWeatherForecast: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.forecastURL)
.then((data) => {
if (!data || !data.properties || !data.properties.periods || !data.properties.periods.length) {
// Did not receive usable new data.
return;
}
const forecast = this.generateWeatherObjectsFromForecast(data.properties.periods);
this.setWeatherForecast(forecast);
})
.catch(function (request) {
Log.error("Could not load forecast hourly data ... ", request);
})
.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 */
/*
* Get specific URLs
*/
fetchWxGovURLs(config) {
this.fetchData(`${config.apiBase}/points/${config.lat},${config.lon}`)
.then((data) => {
if (!data || !data.properties) {
// points URL did not respond with usable data.
return;
}
this.fetchedLocationName = `${data.properties.relativeLocation.properties.city}, ${data.properties.relativeLocation.properties.state}`;
Log.log(`Forecast location is ${this.fetchedLocationName}`);
this.forecastURL = `${data.properties.forecast}?units=si`;
this.forecastHourlyURL = `${data.properties.forecastHourly}?units=si`;
this.forecastGridDataURL = data.properties.forecastGridData;
this.observationStationsURL = data.properties.observationStations;
// with this URL, we chain another promise for the station obs URL
return this.fetchData(data.properties.observationStations);
})
.then((obsData) => {
if (!obsData || !obsData.features) {
// obs station URL did not respond with usable data.
return;
}
this.stationObsURL = `${obsData.features[0].id}/observations/latest`;
})
.catch((err) => {
Log.error(err);
})
.finally(() => {
// excellent, let's fetch some actual wx data
this.configURLs = true;
// handle 'forecast' config, fall back to 'current'
if (config.type === "forecast") {
this.fetchWeatherForecast();
} else if (config.type === "hourly") {
this.fetchWeatherHourly();
} else {
this.fetchCurrentWeather();
}
});
},
/*
* Generate a WeatherObject based on hourlyWeatherInformation
* Weather.gov API uses specific units; API does not include choice of units
* ... object needs data in units based on config!
*/
generateWeatherObjectsFromHourly(forecasts) {
const days = [];
// variable for date
let weather = new WeatherObject();
for (const forecast of forecasts) {
weather.date = moment(forecast.startTime.slice(0, 19));
if (forecast.windSpeed.search(" ") < 0) {
weather.windSpeed = forecast.windSpeed;
} else {
weather.windSpeed = forecast.windSpeed.slice(0, forecast.windSpeed.search(" "));
}
weather.windSpeed = WeatherUtils.convertWindToMs(weather.windSpeed);
weather.windFromDirection = forecast.windDirection;
weather.temperature = forecast.temperature;
// use the forecast isDayTime attribute to help build the weatherType label
weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime);
days.push(weather);
weather = new WeatherObject();
}
// push weather information to days array
days.push(weather);
return days;
},
/*
* Generate a WeatherObject based on currentWeatherInformation
* Weather.gov API uses specific units; API does not include choice of units
* ... object needs data in units based on config!
*/
generateWeatherObjectFromCurrentWeather(currentWeatherData) {
const currentWeather = new WeatherObject();
currentWeather.date = moment(currentWeatherData.timestamp);
currentWeather.temperature = currentWeatherData.temperature.value;
currentWeather.windSpeed = WeatherUtils.convertWindToMs(currentWeatherData.windSpeed.value);
currentWeather.windFromDirection = currentWeatherData.windDirection.value;
currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value;
currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value;
currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value);
currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour.value;
if (currentWeatherData.heatIndex.value !== null) {
currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value;
} else if (currentWeatherData.windChill.value !== null) {
currentWeather.feelsLikeTemp = currentWeatherData.windChill.value;
} else {
currentWeather.feelsLikeTemp = currentWeatherData.temperature.value;
}
// determine the sunrise/sunset times - not supplied in weather.gov data
currentWeather.updateSunTime(this.config.lat, this.config.lon);
// update weatherType
currentWeather.weatherType = this.convertWeatherType(currentWeatherData.textDescription, currentWeather.isDayTime());
return currentWeather;
},
/*
* Generate WeatherObjects based on forecast information
*/
generateWeatherObjectsFromForecast(forecasts) {
return this.fetchForecastDaily(forecasts);
},
/*
* fetch forecast information for daily forecast.
*/
fetchForecastDaily(forecasts) {
const precipitationProbabilityRegEx = "Chance of precipitation is ([0-9]+?)%";
// initial variable declaration
const days = [];
// variables for temperature range and rain
let minTemp = [];
let maxTemp = [];
// variable for date
let date = "";
let weather = new WeatherObject();
for (const forecast of forecasts) {
if (date !== moment(forecast.startTime).format("YYYY-MM-DD")) {
// calculate minimum/maximum temperature, specify rain amount
weather.minTemperature = Math.min.apply(null, minTemp);
weather.maxTemperature = Math.max.apply(null, maxTemp);
// push weather information to days array
days.push(weather);
// create new weather-object
weather = new WeatherObject();
minTemp = [];
maxTemp = [];
const precipitation = new RegExp(precipitationProbabilityRegEx, "g").exec(forecast.detailedForecast);
if (precipitation) weather.precipitationProbability = precipitation[1];
// set new date
date = moment(forecast.startTime).format("YYYY-MM-DD");
// specify date
weather.date = moment(forecast.startTime);
// use the forecast isDayTime attribute to help build the weatherType label
weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime);
}
if (moment(forecast.startTime).format("H") >= 8 && moment(forecast.startTime).format("H") <= 17) {
weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime);
}
// the same day as before
// add values from forecast to corresponding variables
minTemp.push(forecast.temperature);
maxTemp.push(forecast.temperature);
}
// last day
// calculate minimum/maximum temperature
weather.minTemperature = Math.min.apply(null, minTemp);
weather.maxTemperature = Math.max.apply(null, maxTemp);
// push weather information to days array
days.push(weather);
return days.slice(1);
},
/*
* Convert the icons to a more usable name.
*/
convertWeatherType(weatherType, isDaytime) {
//https://w1.weather.gov/xml/current_obs/weather.php
// There are way too many types to create, so lets just look for certain strings
if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) {
if (isDaytime) {
return "day-cloudy";
}
return "night-cloudy";
} else if (weatherType.includes("Overcast")) {
if (isDaytime) {
return "cloudy";
}
return "night-cloudy";
} else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) {
return "rain-mix";
} else if (weatherType.includes("Snow")) {
if (isDaytime) {
return "snow";
}
return "night-snow";
} else if (weatherType.includes("Thunderstorm")) {
if (isDaytime) {
return "thunderstorm";
}
return "night-thunderstorm";
} else if (weatherType.includes("Showers")) {
if (isDaytime) {
return "showers";
}
return "night-showers";
} else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) {
if (isDaytime) {
return "rain";
}
return "night-rain";
} else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) {
if (isDaytime) {
return "cloudy-windy";
}
return "night-alt-cloudy-windy";
} else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) {
if (isDaytime) {
return "day-sunny";
}
return "night-clear";
} else if (weatherType.includes("Dust") || weatherType.includes("Sand")) {
return "dust";
} else if (weatherType.includes("Fog")) {
return "fog";
} else if (weatherType.includes("Smoke")) {
return "smoke";
} else if (weatherType.includes("Haze")) {
return "day-haze";
}
return null;
}
});

@ -0,0 +1,632 @@
/* global WeatherProvider, WeatherObject */
/* MagicMirror²
* Module: Weather
* Provider: Yr.no
*
* By Magnus Marthinsen
* MIT Licensed
*
* This class is a provider for Yr.no, a norwegian weather service.
*
* Terms of service: https://developer.yr.no/doc/TermsOfService/
*/
WeatherProvider.register("yr", {
providerName: "Yr",
// Set the default config properties that is specific to this provider
defaults: {
useCorsProxy: true,
apiBase: "https://api.met.no/weatherapi",
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 stellar 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.precipitationAmount = forecastXHours.details?.precipitation_amount;
forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation;
forecast.minTemperature = forecastXHours.details?.air_temperature_min;
forecast.maxTemperature = forecastXHours.details?.air_temperature_max;
return this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units);
},
getWeatherData() {
return new Promise((resolve, reject) => {
// If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes.
// This is to avoid multiple similar calls to the API.
let shouldWait = localStorage.getItem("yrIsFetchingWeatherData");
if (shouldWait) {
const checkForGo = setInterval(function () {
shouldWait = localStorage.getItem("yrIsFetchingWeatherData");
}, 100);
setTimeout(function () {
clearInterval(checkForGo);
shouldWait = false;
}, 5000); //Assume other fetch finished but failed to remove lock
const attemptFetchWeather = setInterval(() => {
if (!shouldWait) {
clearInterval(checkForGo);
clearInterval(attemptFetchWeather);
this.getWeatherDataFromYrOrCache(resolve, reject);
}
}, 100);
} else {
this.getWeatherDataFromYrOrCache(resolve, reject);
}
});
},
getWeatherDataFromYrOrCache(resolve, reject) {
localStorage.setItem("yrIsFetchingWeatherData", "true");
let weatherData = this.getWeatherDataFromCache();
if (this.weatherDataIsValid(weatherData)) {
localStorage.removeItem("yrIsFetchingWeatherData");
Log.debug("Weather data found in cache.");
resolve(weatherData);
} else {
this.getWeatherDataFromYr(weatherData?.downloadedAt)
.then((weatherData) => {
Log.debug("Got weather data from yr.");
let data;
if (weatherData) {
this.cacheWeatherData(weatherData);
data = weatherData;
} else {
//Undefined if unchanged
data = this.getWeatherDataFromCache();
}
resolve(data);
})
.catch((err) => {
Log.error(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) {
const data = {
today: stellarData
};
data.tomorrow = Object.assign({}, data.today);
data.today.date = today;
data.tomorrow.date = tomorrow;
this.cacheStellarData(data);
resolve(data);
} else {
Log.error(`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();
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.windFromDirection = forecast.data.instant.details.wind_from_direction;
weather.temperature = forecast.data.instant.details.air_temperature;
weather.minTemperature = forecast.minTemperature;
weather.maxTemperature = forecast.maxTemperature;
weather.weatherType = forecast.weatherType;
weather.humidity = forecast.data.instant.details.relative_humidity;
weather.precipitationAmount = forecast.precipitationAmount;
weather.precipitationProbability = forecast.precipitationProbability;
weather.precipitationUnits = units.precipitation_amount;
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 stellar data available.");
}
let forecasts;
switch (type) {
case "hourly":
forecasts = this.getHourlyForecastFrom(weatherData);
break;
case "daily":
default:
forecasts = this.getDailyForecastFrom(weatherData);
break;
}
const series = [];
for (const forecast of forecasts) {
series.push(this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units));
}
return series;
},
getHourlyForecastFrom(weatherData) {
const series = [];
for (const forecast of weatherData.properties.timeseries) {
forecast.symbol = forecast.data.next_1_hours?.summary?.symbol_code;
forecast.precipitationAmount = forecast.data.next_1_hours?.details?.precipitation_amount;
forecast.precipitationProbability = forecast.data.next_1_hours?.details?.probability_of_precipitation;
forecast.minTemperature = forecast.data.next_1_hours?.details?.air_temperature_min;
forecast.maxTemperature = forecast.data.next_1_hours?.details?.air_temperature_max;
forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time);
series.push(forecast);
}
return series;
},
getDailyForecastFrom(weatherData) {
const series = [];
const days = weatherData.properties.timeseries.reduce(function (days, forecast) {
const date = moment(forecast.time).format("YYYY-MM-DD");
days[date] = days[date] || [];
days[date].push(forecast);
return days;
}, Object.create(null));
Object.keys(days).forEach(function (time, 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.precipitationAmount = forecastXHours.details?.precipitation_amount ?? forecast.data.next_6_hours?.details?.precipitation_amount; // 6 hours is likely to have precipitation amount even if 12 hours does not
forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation;
forecast.minTemperature = minTemperature;
forecast.maxTemperature = maxTemperature;
series.push(forecast);
}
});
for (const forecast of series) {
forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time);
}
return series;
},
fetchWeatherForecast() {
this.getWeatherForecast("daily")
.then((forecast) => {
this.setWeatherForecast(forecast);
this.updateAvailable();
})
.catch((error) => {
Log.error(error);
throw new Error(error);
});
}
});

@ -0,0 +1,49 @@
.weather .weathericon,
.weather .fa-home {
font-size: 75%;
line-height: 65px;
display: inline-block;
transform: translate(0, -3px);
}
.weather .humidity-icon {
padding-right: 4px;
}
.weather .humidity-padding {
padding-bottom: 6px;
}
.weather .day {
padding-left: 0;
padding-right: 25px;
}
.weather .weather-icon {
padding-right: 30px;
text-align: center;
}
.weather .min-temp {
padding-left: 20px;
padding-right: 0;
}
.weather .precipitation-amount,
.weather .precipitation-prob,
.weather .uv-index {
padding-left: 20px;
padding-right: 0;
}
.weather tr .weathericon {
line-height: 25px;
}
.weather tr.colored .min-temp {
color: #bcddff;
}
.weather tr.colored .max-temp {
color: #ff8e99;
}

@ -0,0 +1,297 @@
/* global WeatherProvider, WeatherUtils, formatTime */
/* MagicMirror²
* Module: Weather
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*/
Module.register("weather", {
// Default module config.
defaults: {
weatherProvider: "openweathermap",
roundTemp: false,
type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint)
lang: config.language,
units: config.units,
tempUnits: config.units,
windUnits: config.units,
timeFormat: config.timeFormat,
updateInterval: 10 * 60 * 1000, // every 10 minutes
animationSpeed: 1000,
showFeelsLike: true,
showHumidity: false,
showIndoorHumidity: false,
showIndoorTemperature: false,
showPeriod: true,
showPeriodUpper: false,
showPrecipitationAmount: false,
showPrecipitationProbability: false,
showUVIndex: false,
showSun: true,
showWindDirection: true,
showWindDirectionAsArrow: false,
degreeLabel: false,
decimalSymbol: ".",
maxNumberOfDays: 5,
maxEntries: 5,
ignoreToday: false,
fade: true,
fadePoint: 0.25, // Start on 1/4th of the list.
initialLoadDelay: 0, // 0 seconds delay
appendLocationNameToHeader: true,
calendarClass: "calendar",
tableClass: "small",
onlyTemp: false,
colored: false,
absoluteDates: false,
hourlyForecastIncrements: 1
},
// Module properties.
weatherProvider: null,
// Can be used by the provider to display location of event if nothing else is specified
firstEvent: null,
// Define required scripts.
getStyles: function () {
return ["font-awesome.css", "weather-icons.css", "weather.css"];
},
// Return the scripts that are necessary for the weather module.
getScripts: function () {
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.
getHeader: function () {
if (this.config.appendLocationNameToHeader && this.weatherProvider) {
if (this.data.header) return `${this.data.header} ${this.weatherProvider.fetchedLocation()}`;
else return this.weatherProvider.fetchedLocation();
}
return this.data.header ? this.data.header : "";
},
// Start the weather module.
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);
// Let the weather provider know we are starting.
this.weatherProvider.start();
// Add custom filters
this.addFilters();
// Schedule the first update.
this.scheduleUpdate(this.config.initialLoadDelay);
},
// Override notification handler.
notificationReceived: function (notification, payload, sender) {
if (notification === "CALENDAR_EVENTS") {
const senderClasses = sender.data.classes.toLowerCase().split(" ");
if (senderClasses.indexOf(this.config.calendarClass.toLowerCase()) !== -1) {
this.firstEvent = null;
for (let event of payload) {
if (event.location || event.geo) {
this.firstEvent = event;
Log.debug("First upcoming event with location: ", event);
break;
}
}
}
} else if (notification === "INDOOR_TEMPERATURE") {
this.indoorTemperature = this.roundValue(payload);
this.updateDom(300);
} else if (notification === "INDOOR_HUMIDITY") {
this.indoorHumidity = this.roundValue(payload);
this.updateDom(300);
}
},
// Select the template depending on the display type.
getTemplate: function () {
switch (this.config.type.toLowerCase()) {
case "current":
return "current.njk";
case "hourly":
return "hourly.njk";
case "daily":
case "forecast":
return "forecast.njk";
//Make the invalid values use the "Loading..." from forecast
default:
return "forecast.njk";
}
},
// Add all the data to the template.
getTemplateData: function () {
const currentData = this.weatherProvider.currentWeather();
const forecastData = this.weatherProvider.weatherForecast();
// Skip some hourly forecast entries if configured
const hourlyData = this.weatherProvider.weatherHourly()?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1);
return {
config: this.config,
current: currentData,
forecast: forecastData,
hourly: hourlyData,
indoor: {
humidity: this.indoorHumidity,
temperature: this.indoorTemperature
}
};
},
// What to do when the weather provider has new information available?
updateAvailable: function () {
Log.log("New weather information available.");
this.updateDom(0);
this.scheduleUpdate();
if (this.weatherProvider.currentWeather()) {
this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType.replace("-", "_") });
}
const notificationPayload = {
currentWeather: this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null,
forecastArray: this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [],
hourlyArray: this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [],
locationName: this.weatherProvider?.fetchedLocationName,
providerName: this.weatherProvider.providerName
};
this.sendNotification("WEATHER_UPDATED", notificationPayload);
},
scheduleUpdate: function (delay = null) {
let nextLoad = this.config.updateInterval;
if (delay !== null && delay >= 0) {
nextLoad = delay;
}
setTimeout(() => {
switch (this.config.type.toLowerCase()) {
case "current":
this.weatherProvider.fetchCurrentWeather();
break;
case "hourly":
this.weatherProvider.fetchWeatherHourly();
break;
case "daily":
case "forecast":
this.weatherProvider.fetchWeatherForecast();
break;
default:
Log.error(`Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`);
}
}, nextLoad);
},
roundValue: function (temperature) {
const decimals = this.config.roundTemp ? 0 : 1;
const roundValue = parseFloat(temperature).toFixed(decimals);
return roundValue === "-0" ? 0 : roundValue;
},
addFilters() {
this.nunjucksEnvironment().addFilter(
"formatTime",
function (date) {
return formatTime(this.config, date);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"unit",
function (value, type, valueUnit) {
let formattedValue;
if (type === "temperature") {
formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`;
if (this.config.degreeLabel) {
if (this.config.tempUnits === "metric") {
formattedValue += "C";
} else if (this.config.tempUnits === "imperial") {
formattedValue += "F";
} else {
formattedValue += "K";
}
}
} else if (type === "precip") {
if (value === null || isNaN(value) || value === 0 || value.toFixed(2) === "0.00") {
formattedValue = "";
} else {
formattedValue = WeatherUtils.convertPrecipitationUnit(value, valueUnit, this.config.units);
}
} else if (type === "humidity") {
formattedValue = `${value}%`;
} else if (type === "wind") {
formattedValue = WeatherUtils.convertWind(value, this.config.windUnits);
}
return formattedValue;
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"roundValue",
function (value) {
return this.roundValue(value);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"decimalSymbol",
function (value) {
return value.toString().replace(/\./g, this.config.decimalSymbol);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"calcNumSteps",
function (forecast) {
return Math.min(forecast.length, this.config.maxNumberOfDays);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"calcNumEntries",
function (dataArray) {
return Math.min(dataArray.length, this.config.maxEntries);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"opacity",
function (currentStep, numSteps) {
if (this.config.fade && this.config.fadePoint < 1) {
if (this.config.fadePoint < 0) {
this.config.fadePoint = 0;
}
const startingPoint = numSteps * this.config.fadePoint;
const numFadesteps = numSteps - startingPoint;
if (currentStep >= startingPoint) {
return 1 - (currentStep - startingPoint) / numFadesteps;
} else {
return 1;
}
} else {
return 1;
}
}.bind(this)
);
}
});

@ -0,0 +1,136 @@
/* global SunCalc, WeatherUtils */
/* MagicMirror²
* Module: Weather
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*
* This class is the blueprint for a day which includes weather information.
*
* Currently this is focused on the information which is necessary for the current weather.
* As soon as we start implementing the forecast, mode properties will be added.
*/
/**
* @external Moment
*/
class WeatherObject {
/**
* Constructor for a WeatherObject
*/
constructor() {
this.date = null;
this.windSpeed = null;
this.windFromDirection = null;
this.sunrise = null;
this.sunset = null;
this.temperature = null;
this.minTemperature = null;
this.maxTemperature = null;
this.weatherType = null;
this.humidity = null;
this.precipitationAmount = null;
this.precipitationUnits = null;
this.precipitationProbability = null;
this.feelsLikeTemp = null;
}
cardinalWindDirection() {
if (this.windFromDirection > 11.25 && this.windFromDirection <= 33.75) {
return "NNE";
} else if (this.windFromDirection > 33.75 && this.windFromDirection <= 56.25) {
return "NE";
} else if (this.windFromDirection > 56.25 && this.windFromDirection <= 78.75) {
return "ENE";
} else if (this.windFromDirection > 78.75 && this.windFromDirection <= 101.25) {
return "E";
} else if (this.windFromDirection > 101.25 && this.windFromDirection <= 123.75) {
return "ESE";
} else if (this.windFromDirection > 123.75 && this.windFromDirection <= 146.25) {
return "SE";
} else if (this.windFromDirection > 146.25 && this.windFromDirection <= 168.75) {
return "SSE";
} else if (this.windFromDirection > 168.75 && this.windFromDirection <= 191.25) {
return "S";
} else if (this.windFromDirection > 191.25 && this.windFromDirection <= 213.75) {
return "SSW";
} else if (this.windFromDirection > 213.75 && this.windFromDirection <= 236.25) {
return "SW";
} else if (this.windFromDirection > 236.25 && this.windFromDirection <= 258.75) {
return "WSW";
} else if (this.windFromDirection > 258.75 && this.windFromDirection <= 281.25) {
return "W";
} else if (this.windFromDirection > 281.25 && this.windFromDirection <= 303.75) {
return "WNW";
} else if (this.windFromDirection > 303.75 && this.windFromDirection <= 326.25) {
return "NW";
} else if (this.windFromDirection > 326.25 && this.windFromDirection <= 348.75) {
return "NNW";
} else {
return "N";
}
}
/**
* Determines if the sun sets or rises next. Uses the current time and not
* the date from the weather-forecast.
* @param {Moment} date an optional date where you want to get the next
* action for. Useful only in tests, defaults to the current time.
* @returns {string} "sunset" or "sunrise"
*/
nextSunAction(date = moment()) {
return date.isBetween(this.sunrise, this.sunset) ? "sunset" : "sunrise";
}
feelsLike() {
if (this.feelsLikeTemp) {
return this.feelsLikeTemp;
}
return WeatherUtils.calculateFeelsLike(this.temperature, this.windSpeed, this.humidity);
}
/**
* Checks if the weatherObject is at dayTime.
* @returns {boolean} true if it is at dayTime
*/
isDayTime() {
const now = !this.date ? moment() : this.date;
return now.isBetween(this.sunrise, this.sunset, undefined, "[]");
}
/**
* Update the sunrise / sunset time depending on the location. This can be
* 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) {
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);
}
/**
* Clone to simple object to prevent mutating and deprecation of legacy library.
*
* Before being handed to other modules, mutable values must be cloned safely.
* Especially 'moment' object is not immutable, so original 'date', 'sunrise', 'sunset' could be corrupted or changed by other modules.
* @returns {object} plained object clone of original weatherObject
*/
simpleClone() {
const toFlat = ["date", "sunrise", "sunset"];
let clone = { ...this };
for (const prop of toFlat) {
clone[prop] = clone?.[prop]?.valueOf() ?? clone?.[prop];
}
return clone;
}
}
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = WeatherObject;
}

@ -0,0 +1,168 @@
/* global Class, performWebRequest */
/* MagicMirror²
* Module: Weather
*
* By Michael Teeuw https://michaelteeuw.nl
* MIT Licensed.
*
* This class is the blueprint for a weather provider.
*/
const WeatherProvider = Class.extend({
// Weather Provider Properties
providerName: null,
defaults: {},
// The following properties have accessor methods.
// Try to not access them directly.
currentWeatherObject: null,
weatherForecastArray: null,
weatherHourlyArray: null,
fetchedLocationName: null,
// The following properties will be set automatically.
// You do not need to overwrite these properties.
config: null,
delegate: null,
providerIdentifier: null,
// Weather Provider Methods
// All the following methods can be overwritten, although most are good as they are.
// Called when a weather provider is initialized.
init: function (config) {
this.config = config;
Log.info(`Weather provider: ${this.providerName} initialized.`);
},
// Called to set the config, this config is the same as the weather module's config.
setConfig: function (config) {
this.config = config;
Log.info(`Weather provider: ${this.providerName} config set.`, this.config);
},
// Called when the weather provider is about to start.
start: function () {
Log.info(`Weather provider: ${this.providerName} started.`);
},
// This method should start the API request to fetch the current weather.
// This method should definitely be overwritten in the provider.
fetchCurrentWeather: function () {
Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchCurrentWeather method.`);
},
// This method should start the API request to fetch the weather forecast.
// This method should definitely be overwritten in the provider.
fetchWeatherForecast: function () {
Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchWeatherForecast method.`);
},
// This method should start the API request to fetch the weather hourly.
// This method should definitely be overwritten in the provider.
fetchWeatherHourly: function () {
Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchWeatherHourly method.`);
},
// This returns a WeatherDay object for the current weather.
currentWeather: function () {
return this.currentWeatherObject;
},
// This returns an array of WeatherDay objects for the weather forecast.
weatherForecast: function () {
return this.weatherForecastArray;
},
// This returns an object containing WeatherDay object(s) depending on the type of call.
weatherHourly: function () {
return this.weatherHourlyArray;
},
// This returns the name of the fetched location or an empty string.
fetchedLocation: function () {
return this.fetchedLocationName || "";
},
// Set the currentWeather and notify the delegate that new information is available.
setCurrentWeather: function (currentWeatherObject) {
// We should check here if we are passing a WeatherDay
this.currentWeatherObject = currentWeatherObject;
},
// Set the weatherForecastArray and notify the delegate that new information is available.
setWeatherForecast: function (weatherForecastArray) {
// We should check here if we are passing a WeatherDay
this.weatherForecastArray = weatherForecastArray;
},
// Set the weatherHourlyArray and notify the delegate that new information is available.
setWeatherHourly: function (weatherHourlyArray) {
this.weatherHourlyArray = weatherHourlyArray;
},
// Set the fetched location name.
setFetchedLocation: function (name) {
this.fetchedLocationName = name;
},
// Notify the delegate that new weather is available.
updateAvailable: function () {
this.delegate.updateAvailable(this);
},
/**
* 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);
}
const useCorsProxy = typeof this.config.useCorsProxy !== "undefined" && this.config.useCorsProxy;
return performWebRequest(url, type, useCorsProxy, requestHeaders, expectedResponseHeaders);
}
});
/**
* Collection of registered weather providers.
*/
WeatherProvider.providers = [];
/**
* Static method to register a new weather provider.
* @param {string} providerIdentifier The name of the weather provider
* @param {object} providerDetails The details of the weather provider
*/
WeatherProvider.register = function (providerIdentifier, providerDetails) {
WeatherProvider.providers[providerIdentifier.toLowerCase()] = WeatherProvider.extend(providerDetails);
};
/**
* Static method to initialize a new weather provider.
* @param {string} providerIdentifier The name of the weather provider
* @param {object} delegate The weather module
* @returns {object} The new weather provider
*/
WeatherProvider.initialize = function (providerIdentifier, delegate) {
const pi = providerIdentifier.toLowerCase();
const provider = new WeatherProvider.providers[pi]();
const config = Object.assign({}, provider.defaults, delegate.config);
provider.delegate = delegate;
provider.setConfig(config);
provider.providerIdentifier = pi;
if (!provider.providerName) {
provider.providerName = pi;
}
return provider;
};

@ -0,0 +1,142 @@
/* 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 = this.convertWind(speedInMS, "kmh");
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 a value in a given unit to a string with a converted
* value and a postfix matching the output unit system.
* @param {number} value - The value to convert.
* @param {string} valueUnit - The unit the values has. Default is mm.
* @param {string} outputUnit - The unit system (imperial/metric) the return value should have.
* @returns {string} - A string with tha value and a unit postfix.
*/
convertPrecipitationUnit(value, valueUnit, outputUnit) {
if (valueUnit === "%") return `${value.toFixed(0)} ${valueUnit}`;
let convertedValue = value;
let conversionUnit = valueUnit;
if (outputUnit === "imperial") {
if (valueUnit && valueUnit.toLowerCase() === "cm") convertedValue = convertedValue * 0.3937007874;
else convertedValue = convertedValue * 0.03937007874;
conversionUnit = "in";
} else {
conversionUnit = valueUnit ? valueUnit : "mm";
}
return `${convertedValue.toFixed(2)} ${conversionUnit}`;
},
/**
* 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;
},
calculateFeelsLike(temperature, windSpeed, humidity) {
const windInMph = this.convertWind(windSpeed, "imperial");
const tempInF = this.convertTemp(temperature, "imperial");
let feelsLike = tempInF;
if (windInMph > 3 && tempInF < 50) {
feelsLike = Math.round(35.74 + 0.6215 * tempInF - 35.75 * Math.pow(windInMph, 0.16) + 0.4275 * tempInF * Math.pow(windInMph, 0.16));
} else if (tempInF > 80 && humidity > 40) {
feelsLike =
-42.379 +
2.04901523 * tempInF +
10.14333127 * humidity -
0.22475541 * tempInF * humidity -
6.83783 * Math.pow(10, -3) * tempInF * tempInF -
5.481717 * Math.pow(10, -2) * humidity * humidity +
1.22874 * Math.pow(10, -3) * tempInF * tempInF * humidity +
8.5282 * Math.pow(10, -4) * tempInF * humidity * humidity -
1.99 * Math.pow(10, -6) * tempInF * tempInF * humidity * humidity;
}
return ((feelsLike - 32) * 5) / 9;
}
};
if (typeof module !== "undefined") {
module.exports = WeatherUtils;
}

Binary file not shown.

@ -0,0 +1,4 @@
{
"key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAlJJbPtCQVok6fimzbpiatLaJVucnmjdbkMT9RGKA580Zuu2n\n9caQBFqmHpPirdSjYQ1XwCyTXS2PG9EsO8aTza3xoQTscWDUDeOv/817t+WbvZ+Y\njOoe5JQNwyuKVOdM0i982NzfIEU7EzOlCIOfFK7JzxZI3SWCSqaQAAHucj/Vn+/2\nGC3BB2U7od2hbW4mGgStHMjIagp/4Rq1NcfjcvUhqt0rWnkylhNaXwH7UqVGuu+s\nZlImv8e48q2O/Le7RPk/eBHKBeylkl+1KA0+jZK5IeV1Mf/yn4wgfNIlcBuT5AZA\nw1L2UoZkj0njXlxsASYm5oP5crWI5PlGGeZODQIDAQABAoIBAChAGl5DLMd0+BbT\n/1uYFlmdFkon56+9DZ6D78WGFjw2caEV36oTOFMlp9mi4QeNExIpBjv3F5lnzVyQ\n57XuD37qgArKhbAVtn1o0PaxWbIxj2bpBPAwQVxVzACzHA/ydfp/iQhLlltsxhtm\no3BSEFgPHndvJyBamBjXVS3bWBk1S99/JfGtPoUlPpVI9QK3yMwOd02xve+0orZm\nk5pvzwMI/dRR0Do5JdJenTTB6haYcuUomGzGs2JayEhnrGfearqcTziU+97diEjT\nadVUOggKrBoBciEkmT5XaHDenEOyWD/4Q+rpCQQ6AUyqXNbiFWJVxY2gj4QsRpab\n/KFdpsECgYEA8nxbOqujnAz0ofMwAVdy/DRhTHTNqr/6kHl3kcf+fpG9C9wmDnqZ\nWRAD3Mzn8D6GSX2icV0ainuJkKxavOQn3/agAadNBd+zEOfr9Y+mvv2rrfhGAu/Z\niJ7okdYDfzzZfOmSUlPmCZ86l9cDmKIriCbqlPf3vPcvf0OPi0qOsnECgYEAnNoW\n98SPyyyl+z6Ye8ybw3wiad4FTPcBYYMZrDAZOQGwfu2XI58wDxwUFOqDmmfBP2Wm\n5APhbcuHaIyvsVuyaLyItaJ0Gk1HXNUT13YriTRHAspD/O1CDHiBM352riA+7tF/\nOQFMQN8kca+zUTDo3aziZLWxsYG2TLipZfO+q10CgYEAtYwLlaqqDQzaH2J+35JE\nobTp4B9XWG7xvzdiIUB2NvcQbg++YnrB0x+ddLPpN0LosZ8hfvSxCVuizDFuohvE\nCveQJozGqw4n+BFb5XdO5ZHw9oh6inpfCN3IzF9KMPoy70XE6mSsRyny6Xnu1Fke\ndIqGeVAKKG19HzBLCYOhwNECgYBVGFCrnP0yCggGlYAecfPzi04UR3pytfMO1xQ/\ngVy9u7foNLfxgHSPTflrG4vIYg/KeDihraVIbsoIo4LR4uCYx+gXOopolpJnv8Xi\n995Iso7v6ZIXDxTtNRdLO4nhj+b0o720zIp1C1p3Pw42tyUu1pOAdb5wgeHIH8rv\nX9yKlQKBgQCqJe9cA5NoLxNFc7aLDU31w2GYDU3ms8zdgk93kLpoNSkeotl9HUup\nrw0YIGzo6kIfUb7pII6mEtti3T25bTYdY6OZ6E4J92oEK5Y2Ax/5pNbW0o3bGy1H\n6ffopsWzsLfGqbowB3z5IlXuUTocbDoh9fU6QAZEI7EbekuKoD7oeg==\n-----END RSA PRIVATE KEY-----",
"pub": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlJJbPtCQVok6fimzbpia\ntLaJVucnmjdbkMT9RGKA580Zuu2n9caQBFqmHpPirdSjYQ1XwCyTXS2PG9EsO8aT\nza3xoQTscWDUDeOv/817t+WbvZ+YjOoe5JQNwyuKVOdM0i982NzfIEU7EzOlCIOf\nFK7JzxZI3SWCSqaQAAHucj/Vn+/2GC3BB2U7od2hbW4mGgStHMjIagp/4Rq1Ncfj\ncvUhqt0rWnkylhNaXwH7UqVGuu+sZlImv8e48q2O/Le7RPk/eBHKBeylkl+1KA0+\njZK5IeV1Mf/yn4wgfNIlcBuT5AZAw1L2UoZkj0njXlxsASYm5oP5crWI5PlGGeZO\nDQIDAQAB\n-----END PUBLIC KEY-----"
}

@ -0,0 +1,78 @@
# ------------------------------------------------------------
# ingest.161hz.stream
# ------------------------------------------------------------
server {
set $forward_scheme http;
set $server "172.18.0.4";
set $port 4001;
listen 80;
listen [::]:80;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ingest.161hz.stream;
# Let's Encrypt SSL
include conf.d/include/letsencrypt-acme-challenge.conf;
include conf.d/include/ssl-ciphers.conf;
ssl_certificate /etc/letsencrypt/live/npm-1/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/npm-1/privkey.pem;
# Block Exploits
include conf.d/include/block-exploits.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
access_log /data/logs/proxy-host-1_access.log proxy;
error_log /data/logs/proxy-host-1_error.log warn;
location / {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
# Proxy!
include conf.d/include/proxy.conf;
}
# Custom
include /data/nginx/custom/server_proxy[.]conf;
}

@ -0,0 +1,41 @@
# ------------------------------------------------------------
# localhost
# ------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name localhost;
access_log /data/logs/redirection-host-1_access.log standard;
error_log /data/logs/redirection-host-1_error.log warn;
location / {
return 300 $scheme://ingest.161hz.stream$request_uri;
}
# Custom
include /data/nginx/custom/server_redirect[.]conf;
}

@ -0,0 +1,19 @@
version: '3.8'
services:
app:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80'
- '8082:81'
- '443:443'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- proxy
networks:
proxy:
external: true

@ -0,0 +1,23 @@
version: "3.8"
services:
ontime:
container_name: ontime
image: getontime/ontime:latest
ports:
- "4001:4001/tcp"
- "127.0.0.1:8888:8888/udp"
volumes:
- "./ontime-db:/server/preloaded-db"
- "./ontime-styles/override.css:/external/styles/override.css"
environment:
- TZ=Europe/Berlin
restart: unless-stopped
networks:
- proxy
networks:
proxy:
external: true

@ -0,0 +1,23 @@
:root {
--background-color-override: #00324D;
--color-override: #eee;
--secondary-color-override: #eee;
--accent-color-override: #FF99CC;
--label-color-override: #eee;
--timer-color-override: #202020;
--card-background-color-override: #084263;
--font-family-override: "Montserrat", sans-serif;
--font-family-bold-override: "Montserrat", sans-serif;
--timer-progress-bg-override: #eee;
--timer-progress-override: #eee;
}
.timer {
color: black !important;
}
// Overriding font for timers
.timer {
font-family: "Comic Sans MS", serif !important;
}

@ -0,0 +1,22 @@
version: '3.8'
services:
restreamer:
container_name: restreamer1
privileged: true
volumes:
- '/opt/core/config:/core/config'
- '/opt/core/data:/core/data'
ports:
- '8282:8080'
- '8181:8181'
- '1935:1935'
- '1936:1936'
- '6000:6000/udp'
image: 'datarhei/restreamer:latest'
networks:
- proxy
networks:
proxy:
external: true

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

Loading…
Cancel
Save