first commit
@@ -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 = " ";
|
||||
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 |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
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: "Bonjour Pascal!"
|
||||
},
|
||||
|
||||
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 %}
|
||||
|
||||
</sup>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if config.showHumidity and current.humidity %}
|
||||
<span>{{ current.humidity | decimalSymbol }}</span><sup> <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;
|
||||
}
|
||||