chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled
@@ -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,144 @@
|
||||
/* global NotificationFx */
|
||||
|
||||
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",
|
||||
eo: "translations/eo.json",
|
||||
es: "translations/es.json",
|
||||
fr: "translations/fr.json",
|
||||
hu: "translations/hu.json",
|
||||
nl: "translations/nl.json",
|
||||
pt: "translations/pt.json",
|
||||
"pt-br": "translations/pt-br.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("[alert] 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,157 @@
|
||||
/**
|
||||
* 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 () {
|
||||
return false;
|
||||
},
|
||||
onOpen () {
|
||||
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;
|
||||
overflow-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;
|
||||
overflow-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,20 @@
|
||||
{% 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,7 @@
|
||||
{% 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² Ειδοποίηση",
|
||||
"welcome": "Καλώς ήρθατε, η εκκίνηση ήταν επιτυχής!"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "MagicMirror² Notification",
|
||||
"welcome": "Welcome, start was successful!"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "MagicMirror²-sciigo",
|
||||
"welcome": "Bonvenon, lanĉo sukcesis!"
|
||||
}
|
||||
@@ -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": "Notificação do MagicMirror²",
|
||||
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "Notificação do MagicMirror²",
|
||||
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
|
||||
}
|
||||
@@ -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,15 @@
|
||||
.calendar .symbol {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.calendar .title {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.calendar .time {
|
||||
padding-left: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
const ical = require("node-ical");
|
||||
const Log = require("logger");
|
||||
const CalendarFetcherUtils = require("./calendarfetcherutils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* CalendarFetcher - Fetches and parses iCal calendar data
|
||||
* Uses HTTPFetcher for HTTP handling with intelligent error handling
|
||||
* @class
|
||||
*/
|
||||
class CalendarFetcher {
|
||||
|
||||
/**
|
||||
* Creates a new CalendarFetcher instance
|
||||
* @param {string} url - The URL of the calendar to fetch
|
||||
* @param {number} reloadInterval - Time in ms between fetches
|
||||
* @param {string[]} excludedEvents - Event titles to exclude
|
||||
* @param {number} maximumEntries - Maximum number of events to return
|
||||
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
|
||||
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
|
||||
* @param {boolean} includePastEvents - Whether to include past events
|
||||
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
|
||||
*/
|
||||
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
|
||||
this.url = url;
|
||||
this.excludedEvents = excludedEvents;
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.maximumNumberOfDays = maximumNumberOfDays;
|
||||
this.includePastEvents = includePastEvents;
|
||||
|
||||
this.events = [];
|
||||
this.lastFetch = null;
|
||||
this.fetchFailedCallback = () => {};
|
||||
this.eventsReceivedCallback = () => {};
|
||||
|
||||
// Use HTTPFetcher for HTTP handling (Composition)
|
||||
this.httpFetcher = new HTTPFetcher(url, {
|
||||
reloadInterval,
|
||||
auth,
|
||||
selfSignedCert
|
||||
});
|
||||
|
||||
// Wire up HTTPFetcher events
|
||||
this.httpFetcher.on("response", (response) => this.#handleResponse(response));
|
||||
this.httpFetcher.on("error", (errorInfo) => this.fetchFailedCallback(this, errorInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles successful HTTP response
|
||||
* @param {Response} response - The fetch Response object
|
||||
*/
|
||||
async #handleResponse (response) {
|
||||
try {
|
||||
// 304 Not Modified has no body: keep previously fetched events and just re-broadcast them.
|
||||
if (response.status === 304) {
|
||||
this.lastFetch = Date.now();
|
||||
this.broadcastEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
const responseData = await response.text();
|
||||
|
||||
const filteredData = await CalendarFetcherUtils.preFilterICS(responseData, {
|
||||
includePastEvents: this.includePastEvents,
|
||||
maximumNumberOfDays: this.maximumNumberOfDays
|
||||
});
|
||||
|
||||
const parsed = await ical.async.parseICS(filteredData);
|
||||
|
||||
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
|
||||
|
||||
this.events = CalendarFetcherUtils.filterEvents(parsed, {
|
||||
excludedEvents: this.excludedEvents,
|
||||
includePastEvents: this.includePastEvents,
|
||||
maximumEntries: this.maximumEntries,
|
||||
maximumNumberOfDays: this.maximumNumberOfDays
|
||||
});
|
||||
|
||||
this.lastFetch = Date.now();
|
||||
this.broadcastEvents();
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, {
|
||||
message: `iCal parsing failed: ${error.message}`,
|
||||
status: null,
|
||||
errorType: "PARSE_ERROR",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED",
|
||||
retryAfter: this.httpFetcher.reloadInterval,
|
||||
retryCount: 0,
|
||||
url: this.url,
|
||||
originalError: error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts fetching calendar data
|
||||
*/
|
||||
fetchCalendar () {
|
||||
this.httpFetcher.startPeriodicFetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enough time has passed since the last fetch to warrant a new one.
|
||||
* Uses reloadInterval as the threshold to respect user's configured fetchInterval.
|
||||
* @returns {boolean} True if a new fetch should be performed
|
||||
*/
|
||||
shouldRefetch () {
|
||||
if (!this.lastFetch) {
|
||||
return true;
|
||||
}
|
||||
const timeSinceLastFetch = Date.now() - this.lastFetch;
|
||||
return timeSinceLastFetch >= this.httpFetcher.reloadInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts the current events to listeners
|
||||
*/
|
||||
broadcastEvents () {
|
||||
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
|
||||
this.eventsReceivedCallback(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback for successful event fetches
|
||||
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
|
||||
*/
|
||||
onReceive (callback) {
|
||||
this.eventsReceivedCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback for fetch failures
|
||||
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
|
||||
*/
|
||||
onError (callback) {
|
||||
this.fetchFailedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CalendarFetcher;
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @external Moment
|
||||
*/
|
||||
const moment = require("moment-timezone");
|
||||
const ical = require("node-ical");
|
||||
|
||||
const Log = require("logger");
|
||||
|
||||
const CalendarFetcherUtils = {
|
||||
|
||||
/**
|
||||
* Determine based on the title of an event if it should be excluded from the list of events
|
||||
* @param {object} config the global config
|
||||
* @param {string} title the title of the event
|
||||
* @returns {object} excluded: true if the event should be excluded, false otherwise
|
||||
* until: the date until the event should be excluded.
|
||||
*/
|
||||
shouldEventBeExcluded (config, title) {
|
||||
for (const filterConfig of config.excludedEvents) {
|
||||
const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);
|
||||
if (match) {
|
||||
return {
|
||||
excluded: !match.until,
|
||||
until: match.until
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
excluded: false,
|
||||
until: null
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get local timezone.
|
||||
* This method makes it easier to test if different timezones cause problems by changing this implementation.
|
||||
* @returns {string} timezone
|
||||
*/
|
||||
getLocalTimezone () {
|
||||
return moment.tz.guess();
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate the time window of events to keep, as [start, end].
|
||||
* Without includePastEvents the window starts now; otherwise it also
|
||||
* reaches maximumNumberOfDays into the past.
|
||||
* @param {object} config Needs includePastEvents (boolean) and maximumNumberOfDays (number).
|
||||
* @returns {[Date, Date]} The start and end of the window.
|
||||
*/
|
||||
calculateFilterWindow (config) {
|
||||
const today = moment().startOf("day");
|
||||
const start = config.includePastEvents
|
||||
? today.clone().subtract(config.maximumNumberOfDays, "days").toDate()
|
||||
: new Date();
|
||||
const end = today.clone().add(config.maximumNumberOfDays, "days").toDate();
|
||||
return [start, end];
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop ICS data outside the configured time window before it is parsed,
|
||||
* so that node-ical only has to process events we might actually show.
|
||||
* @param {string} rawICS The raw ICS text.
|
||||
* @param {object} config Needs includePastEvents (boolean) and maximumNumberOfDays (number).
|
||||
* @returns {Promise<string>} The filtered ICS text.
|
||||
*/
|
||||
async preFilterICS (rawICS, config) {
|
||||
// ics-filter is ESM-only, so we import it dynamically from this CommonJS file.
|
||||
const { icsFilter } = await import("ics-filter");
|
||||
const [start, end] = CalendarFetcherUtils.calculateFilterWindow(config);
|
||||
return icsFilter(rawICS, start, end);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 {object[]} the filtered events
|
||||
*/
|
||||
filterEvents (data, config) {
|
||||
const newEvents = [];
|
||||
|
||||
Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
|
||||
|
||||
const now = moment();
|
||||
const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now;
|
||||
const futureLocalMoment
|
||||
= now
|
||||
.clone()
|
||||
.startOf("day")
|
||||
.add(config.maximumNumberOfDays, "days")
|
||||
// Subtract 1 second so that events that start on the middle of the night will not repeat.
|
||||
.subtract(1, "seconds");
|
||||
|
||||
Object.values(data).forEach((event) => {
|
||||
if (event.type !== "VEVENT") {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = CalendarFetcherUtils.getTitleFromEvent(event);
|
||||
Log.debug(`title: ${title}`);
|
||||
|
||||
// Return quickly if event should be excluded.
|
||||
const { excluded, until: eventFilterUntil } = CalendarFetcherUtils.shouldEventBeExcluded(config, title);
|
||||
if (excluded) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug(`Event: ${title} | start: ${event.start} | end: ${event.end} | recurring: ${!!event.rrule}`);
|
||||
|
||||
const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false;
|
||||
const geo = event.geo || false;
|
||||
const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false;
|
||||
|
||||
let instances;
|
||||
try {
|
||||
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment);
|
||||
} catch (error) {
|
||||
Log.error(`Could not expand event "${title}": ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const instance of instances) {
|
||||
const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance;
|
||||
|
||||
// Filter logic
|
||||
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const instanceTitle = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
|
||||
|
||||
Log.debug(`saving event: ${instanceTitle}, start: ${startMoment.toDate()}, end: ${endMoment.toDate()}`);
|
||||
newEvents.push({
|
||||
title: instanceTitle,
|
||||
startDate: startMoment.format("x"),
|
||||
endDate: endMoment.format("x"),
|
||||
fullDayEvent: isFullDay,
|
||||
recurringEvent: isRecurring,
|
||||
class: event.class,
|
||||
firstYear: event.start.getFullYear(),
|
||||
location: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.location) || location,
|
||||
geo: instanceEvent.geo || geo,
|
||||
description: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.description) || description
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
newEvents.sort(function (a, b) {
|
||||
return a.startDate - b.startDate;
|
||||
});
|
||||
|
||||
return newEvents;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (event) {
|
||||
return CalendarFetcherUtils.unwrapParameterValue(event.summary || event.description) || "Event";
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts the string value from a node-ical ParameterValue object ({val, params})
|
||||
* or returns the value as-is if it is already a plain string.
|
||||
* This handles ICS properties with parameters, e.g. DESCRIPTION;LANGUAGE=de:Text.
|
||||
* @param {string|object} value The raw value from node-ical
|
||||
* @returns {string|object} The unwrapped string value, or the original value if not a ParameterValue
|
||||
*/
|
||||
unwrapParameterValue (value) {
|
||||
if (value && typeof value === "object" && typeof value.val !== "undefined") {
|
||||
return value.val;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if the user defined time filter should apply
|
||||
* @param {moment.Moment} now Date object using previously created object for consistency
|
||||
* @param {moment.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 (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.isBefore(filterUntil);
|
||||
}
|
||||
|
||||
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 (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.slice(1, -1);
|
||||
}
|
||||
return new RegExp(regexFilter, regexFlags).test(title);
|
||||
} else {
|
||||
return title.includes(filter);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Expands a recurring event into individual event instances using node-ical.
|
||||
* Handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events.
|
||||
* @param {object} event The recurring event object
|
||||
* @param {moment.Moment} pastLocalMoment The past date limit
|
||||
* @param {moment.Moment} futureLocalMoment The future date limit
|
||||
* @returns {object[]} Array of event instances with startMoment/endMoment in the local timezone
|
||||
*/
|
||||
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment) {
|
||||
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
|
||||
|
||||
return ical
|
||||
.expandRecurringEvent(event, {
|
||||
from: pastLocalMoment.toDate(),
|
||||
to: futureLocalMoment.toDate(),
|
||||
includeOverrides: true,
|
||||
excludeExdates: true,
|
||||
expandOngoing: true
|
||||
})
|
||||
.map((inst) => {
|
||||
let startMoment, endMoment;
|
||||
if (inst.isFullDay) {
|
||||
startMoment = moment.tz([inst.start.getFullYear(), inst.start.getMonth(), inst.start.getDate()], localTimezone);
|
||||
endMoment = moment.tz([inst.end.getFullYear(), inst.end.getMonth(), inst.end.getDate()], localTimezone);
|
||||
} else {
|
||||
startMoment = moment(inst.start).tz(localTimezone);
|
||||
endMoment = moment(inst.end).tz(localTimezone);
|
||||
}
|
||||
// Events without DTEND (e.g. reminders) get start === end from node-ical;
|
||||
// extend to end-of-day so they remain visible on the calendar.
|
||||
if (startMoment.valueOf() === endMoment.valueOf()) endMoment = endMoment.endOf("day");
|
||||
return { event: inst.event, startMoment, endMoment, isRecurring: inst.isRecurring, isFullDay: inst.isFullDay };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if an event title matches a specific filter configuration.
|
||||
* @param {string} title The event title to check
|
||||
* @param {string|object} filterConfig The filter configuration (string or object)
|
||||
* @returns {object|null} Object with {until: string|null} if matched, null otherwise
|
||||
*/
|
||||
checkEventAgainstFilter (title, filterConfig) {
|
||||
let filter = filterConfig;
|
||||
let testTitle = title.toLowerCase();
|
||||
let until = null;
|
||||
let useRegex = false;
|
||||
let regexFlags = "g";
|
||||
|
||||
if (filter instanceof Object) {
|
||||
if (typeof filter.until !== "undefined") {
|
||||
until = filter.until;
|
||||
}
|
||||
|
||||
if (typeof filter.regex !== "undefined") {
|
||||
useRegex = filter.regex;
|
||||
}
|
||||
|
||||
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)) {
|
||||
return { until };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = CalendarFetcherUtils;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
const CalendarUtils = {
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a string
|
||||
* @param {string} string The string to capitalize
|
||||
* @returns {string} The capitalized string
|
||||
*/
|
||||
capFirst (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 (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 (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.
|
||||
* @param {string} title The title to transform.
|
||||
* @param {object} titleReplace object definition of parts to be replaced in the title
|
||||
* object definition:
|
||||
* search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calculation, the element matching the year must be in a RegEx group
|
||||
* replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation)
|
||||
* yearmatchgroup: {number,optional} match group for year element
|
||||
* @returns {string} The transformed title.
|
||||
*/
|
||||
titleTransform (title, titleReplace) {
|
||||
let transformedTitle = title;
|
||||
for (let tr in titleReplace) {
|
||||
let transform = titleReplace[tr];
|
||||
if (typeof transform === "object") {
|
||||
if (typeof transform.search !== "undefined" && transform.search !== "" && typeof transform.replace !== "undefined") {
|
||||
let regParts = transform.search.match(/^\/(.+)\/([gim]*)$/);
|
||||
let needle = new RegExp(transform.search, "g");
|
||||
if (regParts) {
|
||||
// the parsed pattern is a regexp with flags.
|
||||
needle = new RegExp(regParts[1], regParts[2]);
|
||||
}
|
||||
|
||||
let replacement = transform.replace;
|
||||
if (typeof transform.yearmatchgroup !== "undefined" && transform.yearmatchgroup !== "") {
|
||||
const yearmatch = [...title.matchAll(needle)];
|
||||
if (yearmatch[0].length >= transform.yearmatchgroup + 1 && yearmatch[0][transform.yearmatchgroup] * 1 >= 1900) {
|
||||
let calcage = new Date().getFullYear() - yearmatch[0][transform.yearmatchgroup] * 1;
|
||||
let searchstr = `$${transform.yearmatchgroup}`;
|
||||
replacement = replacement.replace(searchstr, calcage);
|
||||
}
|
||||
}
|
||||
transformedTitle = transformedTitle.replace(needle, replacement);
|
||||
}
|
||||
}
|
||||
}
|
||||
return transformedTitle;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = CalendarUtils;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
// Load internal alias resolver
|
||||
require("../../js/alias-resolver");
|
||||
const Log = require("logger");
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
Log.log("Create fetcher ...");
|
||||
|
||||
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
|
||||
|
||||
fetcher.onReceive(function (fetcher) {
|
||||
Log.log(fetcher.events);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
fetcher.onError(function (fetcher, error) {
|
||||
Log.log("Fetcher error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
fetcher.startFetch();
|
||||
|
||||
Log.log("Create fetcher done! ");
|
||||
@@ -0,0 +1,101 @@
|
||||
const zlib = require("node:zlib");
|
||||
const NodeHelper = require("node_helper");
|
||||
const Log = require("logger");
|
||||
const CalendarFetcher = require("./calendarfetcher");
|
||||
|
||||
module.exports = NodeHelper.create({
|
||||
// Override start method.
|
||||
start () {
|
||||
Log.log(`Starting node helper for: ${this.name}`);
|
||||
this.fetchers = [];
|
||||
},
|
||||
|
||||
// Override socketNotificationReceived method.
|
||||
socketNotificationReceived (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("No fetcher exists with key: ", key);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
|
||||
return;
|
||||
}
|
||||
this.fetchers[key].fetchCalendar();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
Log.error("Malformed calendar url: ", url, error);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
|
||||
return;
|
||||
}
|
||||
|
||||
let fetcher;
|
||||
let fetchIntervalCorrected;
|
||||
if (typeof this.fetchers[identifier + url] === "undefined") {
|
||||
if (fetchInterval < 60000) {
|
||||
Log.warn(`fetchInterval for url ${url} must be >= 60000`);
|
||||
fetchIntervalCorrected = 60000;
|
||||
}
|
||||
Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchIntervalCorrected || fetchInterval}`);
|
||||
fetcher = new CalendarFetcher(url, fetchIntervalCorrected || fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert);
|
||||
|
||||
fetcher.onReceive((fetcher) => {
|
||||
this.broadcastEvents(fetcher, identifier);
|
||||
});
|
||||
|
||||
fetcher.onError((fetcher, errorInfo) => {
|
||||
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, errorInfo.message || errorInfo);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", {
|
||||
id: identifier,
|
||||
error_type: errorInfo.translationKey
|
||||
});
|
||||
});
|
||||
|
||||
this.fetchers[identifier + url] = fetcher;
|
||||
fetcher.fetchCalendar();
|
||||
} else {
|
||||
Log.log(`Use existing calendarfetcher for url: ${url}`);
|
||||
fetcher = this.fetchers[identifier + url];
|
||||
// Check if calendar data is stale and needs refresh
|
||||
if (fetcher.shouldRefetch()) {
|
||||
Log.log(`Calendar data is stale, fetching fresh data for url: ${url}`);
|
||||
fetcher.fetchCalendar();
|
||||
} else {
|
||||
fetcher.broadcastEvents();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} fetcher the fetcher associated with the calendar
|
||||
* @param {string} identifier the identifier of the calendar
|
||||
*/
|
||||
broadcastEvents (fetcher, identifier) {
|
||||
const checksum = zlib.crc32(Buffer.from(JSON.stringify(fetcher.events), "utf8"));
|
||||
this.sendSocketNotification("CALENDAR_EVENTS", {
|
||||
id: identifier,
|
||||
url: fetcher.url,
|
||||
events: fetcher.events,
|
||||
checksum: checksum
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,318 @@
|
||||
/* global SunCalc, formatTime */
|
||||
|
||||
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, // options: true, false, 'short'
|
||||
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", // DEPRECATED, use CSS instead. Class "clock-second-digital" for digital clock, "clock-second" for analog clock.
|
||||
|
||||
showSunTimes: false, // options: true, false, 'disableNextEvent'
|
||||
showMoonTimes: false, // options: false, 'times' (rise/set), 'percent' (lit percent), 'phase' (current phase), or 'both' (percent & phase)
|
||||
lat: 47.630539,
|
||||
lon: -122.344147
|
||||
},
|
||||
// Define required scripts.
|
||||
getScripts () {
|
||||
return ["moment.js", "moment-timezone.js", "suncalc.js"];
|
||||
},
|
||||
// Define styles.
|
||||
getStyles () {
|
||||
return ["clock_styles.css", "font-awesome.css"];
|
||||
},
|
||||
// Define start sequence.
|
||||
start () {
|
||||
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 () {
|
||||
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";
|
||||
|
||||
/************************************
|
||||
* Create wrappers for DIGITAL clock
|
||||
*/
|
||||
const dateWrapper = document.createElement("div");
|
||||
const timeWrapper = document.createElement("div");
|
||||
const hoursWrapper = document.createElement("span");
|
||||
const minutesWrapper = document.createElement("span");
|
||||
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";
|
||||
hoursWrapper.className = "clock-hour-digital";
|
||||
minutesWrapper.className = "clock-minute-digital";
|
||||
secondsWrapper.className = "clock-second-digital dimmed";
|
||||
sunWrapper.className = "sun dimmed small";
|
||||
moonWrapper.className = "moon dimmed small";
|
||||
weekWrapper.className = "week dimmed medium";
|
||||
|
||||
// Set content of wrappers.
|
||||
const now = moment();
|
||||
if (this.config.timezone) {
|
||||
now.tz(this.config.timezone);
|
||||
}
|
||||
|
||||
if (this.config.showDate) {
|
||||
dateWrapper.innerHTML = now.format(this.config.dateFormat);
|
||||
digitalWrapper.appendChild(dateWrapper);
|
||||
}
|
||||
|
||||
if (this.config.displayType !== "analog" && this.config.showTime) {
|
||||
let hourSymbol = "HH";
|
||||
if (this.config.timeFormat !== 24) {
|
||||
hourSymbol = "h";
|
||||
}
|
||||
|
||||
hoursWrapper.innerHTML = now.format(hourSymbol);
|
||||
minutesWrapper.innerHTML = now.format("mm");
|
||||
|
||||
timeWrapper.appendChild(hoursWrapper);
|
||||
if (this.config.clockBold) {
|
||||
minutesWrapper.classList.add("bold");
|
||||
} else {
|
||||
timeWrapper.innerHTML += ":";
|
||||
}
|
||||
timeWrapper.appendChild(minutesWrapper);
|
||||
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 sunWrapperInnerHTML = "";
|
||||
|
||||
if (this.config.showSunTimes !== "disableNextEvent") {
|
||||
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`;
|
||||
|
||||
sunWrapperInnerHTML = `<span class="${isVisible ? "bright" : ""}"><i class="fas fa-sun" aria-hidden="true"></i> ${untilNextEventString}</span>`;
|
||||
}
|
||||
|
||||
sunWrapperInnerHTML += `<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>`;
|
||||
|
||||
sunWrapper.innerHTML = sunWrapperInnerHTML;
|
||||
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 showFraction = ["both", "percent"].includes(this.config.showMoonTimes);
|
||||
const showUnicode = ["both", "phase"].includes(this.config.showMoonTimes);
|
||||
const illuminatedFractionString = `${Math.round(moonIllumination.fraction * 100)}%`;
|
||||
const image = showUnicode ? [..."🌑🌒🌓🌔🌕🌖🌗🌘"][Math.floor(moonIllumination.phase * 8)] : "<i class=\"fas fa-moon\" aria-hidden=\"true\"></i>";
|
||||
|
||||
moonWrapper.innerHTML
|
||||
= `<span class="${isVisible ? "bright" : ""}">${image} ${showFraction ? 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) {
|
||||
if (this.config.showWeek === "short") {
|
||||
weekWrapper.innerHTML = this.translate("WEEK_SHORT", { weekNumber: now.week() });
|
||||
} else {
|
||||
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/MagicMirrorOrg/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; /* DEPRECATED, to be removed in a future version , use CSS instead */
|
||||
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,118 @@
|
||||
.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: #888888 !important; */
|
||||
|
||||
/* use this instead of secondsColor */
|
||||
|
||||
/* have to use !important, because the code explicitly sets the color currently */
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
.module.clock .digital {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.module.clock .sun,
|
||||
.module.clock .moon {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.module.clock .sun > *,
|
||||
.module.clock .moon > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module.clock .clock-hour-digital {
|
||||
color: var(--color-text-bright);
|
||||
}
|
||||
|
||||
.module.clock .clock-minute-digital {
|
||||
color: var(--color-text-bright);
|
||||
}
|
||||
|
||||
.module.clock .clock-second-digital {
|
||||
color: var(--color-text-dimmed);
|
||||
}
|
||||
@@ -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,316 @@
|
||||
/* global Cron */
|
||||
|
||||
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,
|
||||
remoteFileRefreshInterval: 0,
|
||||
fadeSpeed: 4000,
|
||||
morningStartTime: 3,
|
||||
morningEndTime: 12,
|
||||
afternoonStartTime: 12,
|
||||
afternoonEndTime: 17,
|
||||
random: true,
|
||||
specialDayUnique: false
|
||||
},
|
||||
compliments_new: null,
|
||||
refreshMinimumDelay: 15 * 60 * 1000, // 15 minutes
|
||||
lastIndexUsed: -1,
|
||||
// Set currentweather from module
|
||||
currentWeatherType: "",
|
||||
cron_regex: /^(((\d+,)+\d+|((\d+|[*])[/]\d+|((JAN|FEB|APR|MA[RY]|JU[LN]|AUG|SEP|OCT|NOV|DEC)(-(JAN|FEB|APR|MA[RY]|JU[LN]|AUG|SEP|OCT|NOV|DEC))?))|(\d+-\d+)|\d+(-\d+)?[/]\d+(-\d+)?|\d+|[*]|(MON|TUE|WED|THU|FRI|SAT|SUN)(-(MON|TUE|WED|THU|FRI|SAT|SUN))?) ?){5}$/i,
|
||||
date_regex: "[1-9.][0-9.][0-9.]{2}-([0][1-9]|[1][0-2])-([1-2][0-9]|[0][1-9]|[3][0-1])",
|
||||
pre_defined_types: ["anytime", "morning", "afternoon", "evening"],
|
||||
// Define required scripts.
|
||||
getScripts () {
|
||||
return ["croner.js", "moment.js"];
|
||||
},
|
||||
|
||||
// Define start sequence.
|
||||
async start () {
|
||||
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();
|
||||
if (this.config.remoteFileRefreshInterval !== 0) {
|
||||
if ((this.config.remoteFileRefreshInterval >= this.refreshMinimumDelay) || window.mmTestMode === "true") {
|
||||
setInterval(async () => {
|
||||
const response = await this.loadComplimentFile();
|
||||
if (response) {
|
||||
this.compliments_new = JSON.parse(response);
|
||||
}
|
||||
else {
|
||||
Log.error(`[compliments] ${this.name} remoteFile refresh failed`);
|
||||
}
|
||||
},
|
||||
this.config.remoteFileRefreshInterval);
|
||||
} else {
|
||||
Log.error(`[compliments] ${this.name} remoteFileRefreshInterval less than minimum`);
|
||||
}
|
||||
}
|
||||
}
|
||||
let minute_sync_delay = 1;
|
||||
// loop thru all the configured when events
|
||||
for (let m of Object.keys(this.config.compliments)) {
|
||||
// if it is a cron entry
|
||||
if (this.isCronEntry(m)) {
|
||||
// we need to synch our interval cycle to the minute
|
||||
minute_sync_delay = (60 - (moment().second())) * 1000;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Schedule update timer. sync to the minute start (if needed), so minute based events happen on the minute start
|
||||
setTimeout(() => {
|
||||
setInterval(() => {
|
||||
this.updateDom(this.config.fadeSpeed);
|
||||
}, this.config.updateInterval);
|
||||
},
|
||||
minute_sync_delay);
|
||||
},
|
||||
|
||||
// check to see if this entry could be a cron entry which contains spaces
|
||||
isCronEntry (entry) {
|
||||
return entry.includes(" ");
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} cronExpression The cron expression. See https://croner.56k.guru/usage/pattern/
|
||||
* @param {Date} [timestamp] The timestamp to check. Defaults to the current time.
|
||||
* @returns {number} The number of seconds until the next cron run.
|
||||
*/
|
||||
getSecondsUntilNextCronRun (cronExpression, timestamp = new Date()) {
|
||||
// Required for seconds precision
|
||||
const adjustedTimestamp = new Date(timestamp.getTime() - 1000);
|
||||
|
||||
// https://www.npmjs.com/package/croner
|
||||
const cronJob = new Cron(cronExpression);
|
||||
const nextRunTime = cronJob.nextRun(adjustedTimestamp);
|
||||
|
||||
const secondsDelta = (nextRunTime - adjustedTimestamp) / 1000;
|
||||
return secondsDelta;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a random index for a list of compliments.
|
||||
* @param {string[]} compliments Array with compliments.
|
||||
* @returns {number} a random index of given array
|
||||
*/
|
||||
randomIndex (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 () {
|
||||
const now = moment();
|
||||
const hour = now.hour();
|
||||
const date = now.format("YYYY-MM-DD");
|
||||
let compliments = [];
|
||||
|
||||
// Add time of day compliments
|
||||
let timeOfDay;
|
||||
if (hour >= this.config.morningStartTime && hour < this.config.morningEndTime) {
|
||||
timeOfDay = "morning";
|
||||
} else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime) {
|
||||
timeOfDay = "afternoon";
|
||||
} else {
|
||||
timeOfDay = "evening";
|
||||
}
|
||||
|
||||
if (this.config.compliments.hasOwnProperty(timeOfDay)) {
|
||||
compliments = [...this.config.compliments[timeOfDay]];
|
||||
}
|
||||
|
||||
// Add compliments based on weather
|
||||
if (this.currentWeatherType in this.config.compliments) {
|
||||
Array.prototype.push.apply(compliments, this.config.compliments[this.currentWeatherType]);
|
||||
// if the predefine list doesn't include it (yet)
|
||||
if (!this.pre_defined_types.includes(this.currentWeatherType)) {
|
||||
// add it
|
||||
this.pre_defined_types.push(this.currentWeatherType);
|
||||
}
|
||||
}
|
||||
|
||||
// Add compliments for anytime
|
||||
Array.prototype.push.apply(compliments, this.config.compliments.anytime);
|
||||
|
||||
// get the list of just date entry keys
|
||||
let temp_list = Object.keys(this.config.compliments).filter((k) => {
|
||||
if (this.pre_defined_types.includes(k)) return false;
|
||||
else return true;
|
||||
});
|
||||
|
||||
let date_compliments = [];
|
||||
// Add compliments for special day/times
|
||||
for (let entry of temp_list) {
|
||||
// check if this could be a cron type entry
|
||||
if (this.isCronEntry(entry)) {
|
||||
// make sure the regex is valid
|
||||
if (new RegExp(this.cron_regex).test(entry)) {
|
||||
// check if we are in the time range for the cron entry
|
||||
if (this.getSecondsUntilNextCronRun(entry, now.set("seconds", 0).toDate()) <= 1) {
|
||||
// if so, use its notice entries
|
||||
Array.prototype.push.apply(date_compliments, this.config.compliments[entry]);
|
||||
}
|
||||
} else Log.error(`[compliments] cron syntax invalid=${JSON.stringify(entry)}`);
|
||||
} else if (new RegExp(entry).test(date)) {
|
||||
Array.prototype.push.apply(date_compliments, this.config.compliments[entry]);
|
||||
}
|
||||
}
|
||||
|
||||
// if we found any date compliments
|
||||
if (date_compliments.length) {
|
||||
// and the special flag is true
|
||||
if (this.config.specialDayUnique) {
|
||||
// clear the non-date compliments if any
|
||||
compliments.length = 0;
|
||||
}
|
||||
// put the date based compliments on the list
|
||||
Array.prototype.push.apply(compliments, date_compliments);
|
||||
}
|
||||
|
||||
return compliments;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a file from the local filesystem
|
||||
* @returns {Promise<string|null>} Resolved with file content or null on error
|
||||
*/
|
||||
async loadComplimentFile () {
|
||||
const { remoteFile, remoteFileRefreshInterval } = this.config;
|
||||
const isRemote = remoteFile.startsWith("http://") || remoteFile.startsWith("https://");
|
||||
let url = isRemote ? remoteFile : this.file(remoteFile);
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
const urlObj = new URL(url);
|
||||
// Add cache-busting parameter to remote URLs to prevent cached responses
|
||||
if (isRemote && remoteFileRefreshInterval !== 0) {
|
||||
urlObj.searchParams.set("dummy", Date.now());
|
||||
}
|
||||
url = urlObj.toString();
|
||||
} catch {
|
||||
Log.warn(`[compliments] Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
Log.error(`[compliments] HTTP error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
Log.info("[compliments] fetch failed:", error.message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a random compliment.
|
||||
* @returns {string} a compliment
|
||||
*/
|
||||
getRandomCompliment () {
|
||||
// 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 () {
|
||||
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);
|
||||
}
|
||||
// if a new set of compliments was loaded from the refresh task
|
||||
// we do this here to make sure no other function is using the compliments list
|
||||
if (this.compliments_new) {
|
||||
// use them
|
||||
if (JSON.stringify(this.config.compliments) !== JSON.stringify(this.compliments_new)) {
|
||||
// only reset if the contents changes
|
||||
this.config.compliments = this.compliments_new;
|
||||
// reset the index
|
||||
this.lastIndexUsed = -1;
|
||||
}
|
||||
// clear new file list so we don't waste cycles comparing between refreshes
|
||||
this.compliments_new = null;
|
||||
}
|
||||
// only in test mode
|
||||
if (window.mmTestMode === "true") {
|
||||
// check for (undocumented) remoteFile2 to test new file load
|
||||
if (this.config.remoteFile2 !== null && this.config.remoteFileRefreshInterval !== 0) {
|
||||
// switch the file so that next time it will be loaded from a changed file
|
||||
this.config.remoteFile = this.config.remoteFile2;
|
||||
}
|
||||
}
|
||||
return wrapper;
|
||||
},
|
||||
|
||||
// Override notification handler.
|
||||
notificationReceived (notification, payload) {
|
||||
if (notification === "CURRENTWEATHER_TYPE") {
|
||||
this.currentWeatherType = payload.type;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Default Modules List
|
||||
* Modules listed below can be loaded without the 'default/' prefix. Omitting the default folder name.
|
||||
*/
|
||||
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,14 @@
|
||||
Module.register("helloworld", {
|
||||
// Default module config.
|
||||
defaults: {
|
||||
text: "Hello World!"
|
||||
},
|
||||
|
||||
getTemplate () {
|
||||
return "helloworld.njk";
|
||||
},
|
||||
|
||||
getTemplateData () {
|
||||
return this.config;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<!--
|
||||
Use ` | safe` to allow html tags 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,36 @@
|
||||
.newsfeed-fullarticle-container {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
z-index: 1000;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.newsfeed-fullarticle-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
iframe.newsfeed-fullarticle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 5000px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.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,502 @@
|
||||
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,
|
||||
allowedBasicHtmlTags: []
|
||||
},
|
||||
|
||||
getUrlPrefix (item) {
|
||||
if (item.useCorsProxy) {
|
||||
return `${location.protocol}//${location.host}${config.basePath}cors?url=`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
// Define required scripts.
|
||||
getScripts () {
|
||||
return ["moment.js"];
|
||||
},
|
||||
|
||||
//Define required styles.
|
||||
getStyles () {
|
||||
return ["newsfeed.css"];
|
||||
},
|
||||
|
||||
// Define required translations.
|
||||
getTranslations () {
|
||||
// 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;
|
||||
},
|
||||
|
||||
// Define start sequence.
|
||||
start () {
|
||||
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.articleIframe = null;
|
||||
this.articleContainer = null;
|
||||
this.articleFrameCheckPending = false;
|
||||
this.articleUnavailable = false;
|
||||
|
||||
this.registerFeeds();
|
||||
|
||||
this.isShowingDescription = this.config.showDescription;
|
||||
},
|
||||
|
||||
// Override socket notification handler.
|
||||
socketNotificationReceived (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();
|
||||
} else if (notification === "ARTICLE_URL_STATUS") {
|
||||
if (this.config.showFullArticle) {
|
||||
this.articleFrameCheckPending = false;
|
||||
this.articleUnavailable = !payload.canFrame;
|
||||
if (!this.articleUnavailable) {
|
||||
// Article can be framed — now shift the bottom bar to allow scrolling
|
||||
document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle");
|
||||
}
|
||||
this.updateDom(100);
|
||||
if (this.articleUnavailable) {
|
||||
// Briefly show the unavailable message, then return to normal newsfeed view
|
||||
setTimeout(() => {
|
||||
this.resetDescrOrFullArticleAndTimer();
|
||||
this.updateDom(500);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
//Override getDom to handle the full article case with error handling
|
||||
getDom () {
|
||||
if (this.config.showFullArticle) {
|
||||
this.activeItemHash = this.newsItems[this.activeItem]?.hash;
|
||||
const wrapper = document.createElement("div");
|
||||
if (this.articleFrameCheckPending) {
|
||||
// Still waiting for the server-side framing check
|
||||
wrapper.innerHTML = `<div class="small dimmed">${this.translate("LOADING")}</div>`;
|
||||
} else if (this.articleUnavailable) {
|
||||
wrapper.innerHTML = `<div class="small dimmed">${this.translate("NEWSFEED_ARTICLE_UNAVAILABLE")}</div>`;
|
||||
} else {
|
||||
const container = document.createElement("div");
|
||||
container.className = "newsfeed-fullarticle-container";
|
||||
container.scrollTop = this.scrollPosition;
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "newsfeed-fullarticle";
|
||||
// Always use the direct article URL — the CORS proxy is for server-side
|
||||
// RSS feed fetching, not for browser iframes.
|
||||
const item = this.newsItems[this.activeItem];
|
||||
iframe.src = item ? (typeof item.url === "string" ? item.url : item.url.href) : "";
|
||||
this.articleIframe = iframe;
|
||||
this.articleContainer = container;
|
||||
container.appendChild(iframe);
|
||||
wrapper.appendChild(container);
|
||||
}
|
||||
return Promise.resolve(wrapper);
|
||||
}
|
||||
return Module.prototype.getDom.call(this);
|
||||
},
|
||||
|
||||
//Override fetching of template name
|
||||
getTemplate () {
|
||||
if (this.config.feedUrl) {
|
||||
return "oldconfig.njk";
|
||||
}
|
||||
return "newsfeed.njk";
|
||||
},
|
||||
|
||||
//Override template data and return whats used for the current template
|
||||
getTemplateData () {
|
||||
if (this.activeItem >= this.newsItems.length) {
|
||||
this.activeItem = 0;
|
||||
}
|
||||
this.activeItemCount = this.newsItems.length;
|
||||
if (this.error) {
|
||||
this.activeItemHash = undefined;
|
||||
return {
|
||||
error: this.error
|
||||
};
|
||||
}
|
||||
if (this.newsItems.length === 0) {
|
||||
this.activeItemHash = undefined;
|
||||
return {
|
||||
empty: true
|
||||
};
|
||||
}
|
||||
|
||||
const item = this.newsItems[this.activeItem];
|
||||
this.activeItemHash = item.hash;
|
||||
|
||||
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.getActiveItemURL(),
|
||||
description: item.description,
|
||||
items: items
|
||||
};
|
||||
},
|
||||
|
||||
getActiveItemURL () {
|
||||
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 () {
|
||||
for (let feed of this.config.feeds) {
|
||||
this.sendSocketNotification("ADD_FEED", {
|
||||
feed: feed,
|
||||
config: this.config
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets a feed property by name
|
||||
* @param {object} feed A feed object.
|
||||
* @param {string} property The name of the property.
|
||||
* @returns {string} The value of the specified property for the feed.
|
||||
*/
|
||||
getFeedProperty (feed, property) {
|
||||
let res = this.config[property];
|
||||
const f = this.config.feeds.find((feedItem) => feedItem.url === feed);
|
||||
if (f && f[property]) res = f[property];
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an ordered list of items for this configured module.
|
||||
* @param {object} feeds An object with feeds returned by the node helper.
|
||||
*/
|
||||
generateFeed (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.getFeedProperty(feed, "ignoreOldItems") && Date.now() - new Date(item.pubdate) > this.getFeedProperty(feed, "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 (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 (feedUrl) {
|
||||
for (let feed of this.config.feeds) {
|
||||
if (feed.url === feedUrl) {
|
||||
return feed.title || "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
/**
|
||||
* Schedule visual update.
|
||||
*/
|
||||
scheduleUpdateInterval () {
|
||||
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(() => {
|
||||
|
||||
/*
|
||||
* When animations are enabled, don't update the DOM unless we are actually changing what we are displaying.
|
||||
* (Animating from a headline to itself is unsightly.)
|
||||
* Cases:
|
||||
*
|
||||
* Number of items | Number of items | Display
|
||||
* at last update | right now | Behaviour
|
||||
* ----------------------------------------------------
|
||||
* 0 | 0 | do not update
|
||||
* 0 | >0 | update
|
||||
* 1 | 0 or >1 | update
|
||||
* 1 | 1 | update only if item details (hash value) changed
|
||||
* >1 | any | update
|
||||
*
|
||||
* (N.B. We set activeItemCount and activeItemHash in getTemplateData().)
|
||||
*/
|
||||
if (this.newsItems.length > 1 || this.newsItems.length !== this.activeItemCount || this.activeItemHash !== this.newsItems[0]?.hash) {
|
||||
this.activeItem++; // this is OK if newsItems.Length==1; getTemplateData will wrap it around
|
||||
this.updateDom(this.config.animationSpeed);
|
||||
}
|
||||
|
||||
// Broadcast NewsFeed if needed
|
||||
if (this.config.broadcastNewsFeeds) {
|
||||
this.sendNotification("NEWS_FEED", { items: this.newsItems });
|
||||
}
|
||||
}, this.config.updateInterval);
|
||||
},
|
||||
|
||||
resetDescrOrFullArticleAndTimer () {
|
||||
this.isShowingDescription = this.config.showDescription;
|
||||
this.config.showFullArticle = false;
|
||||
this.scrollPosition = 0;
|
||||
this.articleIframe = null;
|
||||
this.articleContainer = null;
|
||||
this.articleFrameCheckPending = false;
|
||||
this.articleUnavailable = false;
|
||||
// reset bottom bar alignment
|
||||
document.getElementsByClassName("region bottom bar")[0].classList.remove("newsfeed-fullarticle");
|
||||
if (!this.timer) {
|
||||
this.scheduleUpdateInterval();
|
||||
}
|
||||
},
|
||||
|
||||
notificationReceived (notification) {
|
||||
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(`[newsfeed] 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(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
|
||||
this.updateDom(100);
|
||||
}
|
||||
else if (notification === "ARTICLE_MORE_DETAILS") {
|
||||
if (this.config.showFullArticle === true) {
|
||||
// iframe already showing — scroll down
|
||||
this.scrollPosition += this.config.scrollLength;
|
||||
if (this.articleContainer) this.articleContainer.scrollTop = this.scrollPosition;
|
||||
Log.debug(`[newsfeed] scrolling down, offset: ${this.scrollPosition}`);
|
||||
} else if (this.isShowingDescription) {
|
||||
// description visible — step up to full article
|
||||
this.showFullArticle();
|
||||
} else {
|
||||
// only title visible — show description first
|
||||
this.isShowingDescription = true;
|
||||
Log.debug("[newsfeed] showing article description");
|
||||
this.updateDom(100);
|
||||
}
|
||||
} else if (notification === "ARTICLE_SCROLL_UP") {
|
||||
if (this.config.showFullArticle === true) {
|
||||
this.scrollPosition = Math.max(0, this.scrollPosition - this.config.scrollLength);
|
||||
if (this.articleContainer) this.articleContainer.scrollTop = this.scrollPosition;
|
||||
Log.debug(`[newsfeed] scrolling up, offset: ${this.scrollPosition}`);
|
||||
}
|
||||
} else if (notification === "ARTICLE_LESS_DETAILS") {
|
||||
this.resetDescrOrFullArticleAndTimer();
|
||||
Log.debug("[newsfeed] 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") {
|
||||
const infoItem = this.newsItems[this.activeItem];
|
||||
if (infoItem) {
|
||||
this.sendNotification("ARTICLE_INFO_RESPONSE", {
|
||||
title: infoItem.title,
|
||||
source: infoItem.sourceTitle,
|
||||
date: infoItem.pubdate,
|
||||
desc: infoItem.description,
|
||||
url: typeof infoItem.url === "string" ? infoItem.url : infoItem.url.href
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
showFullArticle () {
|
||||
const item = this.newsItems[this.activeItem];
|
||||
const hasUrl = item && item.url && (typeof item.url === "string" ? item.url : item.url.href);
|
||||
if (!hasUrl) {
|
||||
Log.debug("[newsfeed] no article URL available, skipping full article view");
|
||||
return;
|
||||
}
|
||||
this.isShowingDescription = false;
|
||||
this.config.showFullArticle = true;
|
||||
// Check server-side whether the article URL allows framing.
|
||||
// The bottom bar CSS class is only added once we know the iframe will be shown.
|
||||
this.articleFrameCheckPending = true;
|
||||
this.articleUnavailable = false;
|
||||
const rawUrl = typeof item.url === "string" ? item.url : item.url.href;
|
||||
this.sendSocketNotification("CHECK_ARTICLE_URL", { url: rawUrl });
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
Log.debug("[newsfeed] showing full article");
|
||||
this.updateDom(100);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
{% 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 or (config.allowedBasicHtmlTags | length), 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 or (config.allowedBasicHtmlTags | length)) }}
|
||||
{% else %}
|
||||
{{ escapeText(item.description, config.dangerouslyDisableAutoEscaping or (config.allowedBasicHtmlTags | length)) }}
|
||||
{% 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 or (config.allowedBasicHtmlTags | length), 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 or (config.allowedBasicHtmlTags | length)) }}
|
||||
{% else %}
|
||||
{{ escapeText(description, config.dangerouslyDisableAutoEscaping or (config.allowedBasicHtmlTags | length)) }}
|
||||
{% 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,248 @@
|
||||
const crypto = require("node:crypto");
|
||||
const stream = require("node:stream");
|
||||
const FeedMe = require("feedme");
|
||||
const iconv = require("iconv-lite");
|
||||
const { htmlToText } = require("html-to-text");
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
// The complete set of basic formatting tags users are allowed to opt into via the
|
||||
// `allowedBasicHtmlTags` config option. These are inline emphasis / line-break tags that
|
||||
// never carry attributes once sanitized, so they cannot be used for injection. Anything
|
||||
// requested outside this list is ignored (see the constructor).
|
||||
const SAFE_HTML_TAGS = ["b", "strong", "i", "em", "u", "br", "code", "s", "sub", "sup"];
|
||||
|
||||
// html-to-text formatter that re-emits an allowed inline tag around its content,
|
||||
// so feeds that send real <em>/<strong> elements keep their emphasis. `br` is a void
|
||||
// element, so it is emitted as a single self-contained tag with no children/closing tag.
|
||||
const keepTagFormatter = (elem, walk, builder, formatOptions) => {
|
||||
const { tagName } = formatOptions;
|
||||
if (tagName === "br") {
|
||||
builder.addLiteral("<br>");
|
||||
return;
|
||||
}
|
||||
builder.addLiteral(`<${tagName}>`);
|
||||
walk(elem.children, builder);
|
||||
builder.addLiteral(`</${tagName}>`);
|
||||
};
|
||||
|
||||
/**
|
||||
* NewsfeedFetcher - Fetches and parses RSS/Atom feed data
|
||||
* Uses HTTPFetcher for HTTP handling with intelligent error handling
|
||||
* @class
|
||||
*/
|
||||
class NewsfeedFetcher {
|
||||
|
||||
/**
|
||||
* Creates a new NewsfeedFetcher instance
|
||||
* @param {string} url - The URL of the news feed to fetch
|
||||
* @param {number} reloadInterval - Time in ms between fetches
|
||||
* @param {string} encoding - Encoding of the feed (e.g., 'UTF-8')
|
||||
* @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
|
||||
* @param {string[]} allowedBasicHtmlTags - Basic formatting tags to keep in title and description. Only tags from the safe list are honored; anything else is ignored.
|
||||
*/
|
||||
constructor (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy, allowedBasicHtmlTags = []) {
|
||||
this.url = url;
|
||||
this.encoding = encoding;
|
||||
this.logFeedWarnings = logFeedWarnings;
|
||||
this.useCorsProxy = useCorsProxy;
|
||||
|
||||
// Keep only tags from the hardcoded safe list; warn about (and ignore) anything else.
|
||||
const requestedTags = (Array.isArray(allowedBasicHtmlTags) ? allowedBasicHtmlTags : []).map((tag) => String(tag).trim().toLowerCase());
|
||||
this.allowedBasicHtmlTags = requestedTags.filter((tag) => SAFE_HTML_TAGS.includes(tag));
|
||||
const ignoredTags = requestedTags.filter((tag) => !SAFE_HTML_TAGS.includes(tag));
|
||||
if (ignoredTags.length > 0) {
|
||||
Log.warn(`Ignoring unsupported allowedBasicHtmlTags [${ignoredTags.join(", ")}] for url ${url}. Allowed tags are: ${SAFE_HTML_TAGS.join(", ")}`);
|
||||
}
|
||||
|
||||
this.items = [];
|
||||
this.fetchFailedCallback = () => {};
|
||||
this.itemsReceivedCallback = () => {};
|
||||
|
||||
// Use HTTPFetcher for HTTP handling (Composition)
|
||||
this.httpFetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: Math.max(reloadInterval, 1000),
|
||||
headers: {
|
||||
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
|
||||
Pragma: "no-cache"
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up HTTPFetcher events
|
||||
this.httpFetcher.on("response", (response) => void this.#handleResponse(response));
|
||||
this.httpFetcher.on("error", (errorInfo) => this.fetchFailedCallback(this, errorInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a feed string, keeping only the given allowlist of basic
|
||||
* formatting tags and neutralizing everything else.
|
||||
*
|
||||
* The approach is allowlist-only and therefore safe to render unescaped:
|
||||
* html-to-text first strips all real markup (scripts, links, images, …) and
|
||||
* decodes entities to text, then EVERYTHING is HTML-escaped and ONLY the exact,
|
||||
* attribute-free allowlisted tags are restored. No attributes, event handlers,
|
||||
* or other tags can survive, so arbitrary HTML/script injection is impossible.
|
||||
* @param {string} html - The raw title or description from the feed.
|
||||
* @param {string[]} [allowedTags] - Tags to keep. Callers pass an already-validated subset of SAFE_HTML_TAGS.
|
||||
* @returns {string} Safe HTML containing at most the allowed formatting tags.
|
||||
*/
|
||||
static sanitizeBasicHtml (html, allowedTags = []) {
|
||||
// `br` keeps its default "collapse to a space" behavior unless explicitly allowed.
|
||||
const keepTagSelectors = allowedTags.map((tagName) => ({ selector: tagName, format: "keepTag", options: { tagName } }));
|
||||
|
||||
const text = htmlToText(html, {
|
||||
wordwrap: false,
|
||||
formatters: { keepTag: keepTagFormatter },
|
||||
selectors: [
|
||||
{ selector: "a", options: { ignoreHref: true, noAnchorUrl: true } },
|
||||
{ selector: "br", format: "inlineSurround", options: { prefix: " " } },
|
||||
{ selector: "img", format: "skip" },
|
||||
...keepTagSelectors
|
||||
]
|
||||
});
|
||||
|
||||
const escaped = text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
|
||||
if (allowedTags.length === 0) {
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// Restore only the exact, attribute-free allowed opening/closing tags after escaping.
|
||||
const restoreAllowedTags = new RegExp(`<(/?(?:${allowedTags.join("|")}))>`, "g");
|
||||
return escaped.replace(restoreAllowedTags, "<$1>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a parse error info object
|
||||
* @param {string} message - Error message
|
||||
* @param {Error} error - Original error
|
||||
* @returns {object} Error info object
|
||||
*/
|
||||
#createParseError (message, error) {
|
||||
return {
|
||||
message,
|
||||
status: null,
|
||||
errorType: "PARSE_ERROR",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED",
|
||||
retryAfter: this.httpFetcher.reloadInterval,
|
||||
retryCount: 0,
|
||||
url: this.url,
|
||||
originalError: error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles successful HTTP response
|
||||
* @param {Response} response - The fetch Response object
|
||||
*/
|
||||
async #handleResponse (response) {
|
||||
// 304 Not Modified has no body: keep previously fetched items and re-broadcast them.
|
||||
if (response.status === 304) {
|
||||
this.broadcastItems();
|
||||
return;
|
||||
}
|
||||
|
||||
this.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"] || item["a10:updated"];
|
||||
const url = item.url || item.link || "";
|
||||
|
||||
if (title && pubdate) {
|
||||
let displayTitle = title;
|
||||
if (this.allowedBasicHtmlTags.length > 0) {
|
||||
// Keep the configured basic formatting tags in both fields, strip everything else
|
||||
description = NewsfeedFetcher.sanitizeBasicHtml(description, this.allowedBasicHtmlTags);
|
||||
displayTitle = NewsfeedFetcher.sanitizeBasicHtml(title, this.allowedBasicHtmlTags);
|
||||
} else {
|
||||
// Convert HTML entities, codes and tag
|
||||
description = htmlToText(description, {
|
||||
wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: "a", options: { ignoreHref: true, noAnchorUrl: true } },
|
||||
{ selector: "br", format: "inlineSurround", options: { prefix: " " } },
|
||||
{ selector: "img", format: "skip" }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
this.items.push({
|
||||
title: displayTitle,
|
||||
description,
|
||||
pubdate,
|
||||
url,
|
||||
useCorsProxy: this.useCorsProxy,
|
||||
// Hash on the original title so the dedup identity is stable regardless of allowedBasicHtmlTags
|
||||
hash: crypto.createHash("sha256").update(`${pubdate} :: ${title} :: ${url}`).digest("hex")
|
||||
});
|
||||
} else if (this.logFeedWarnings) {
|
||||
Log.warn("Can't parse feed item:", item);
|
||||
Log.warn(`Title: ${title}`);
|
||||
Log.warn(`Description: ${description}`);
|
||||
Log.warn(`Pubdate: ${pubdate}`);
|
||||
}
|
||||
});
|
||||
|
||||
parser.on("end", () => this.broadcastItems());
|
||||
|
||||
parser.on("ttl", (minutes) => {
|
||||
const ttlms = Math.min(minutes * 60 * 1000, 86400000);
|
||||
if (ttlms > this.httpFetcher.reloadInterval) {
|
||||
this.httpFetcher.reloadInterval = ttlms;
|
||||
Log.info(`reloadInterval set to ttl=${ttlms} for url ${this.url}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const nodeStream = response.body instanceof stream.Readable
|
||||
? response.body
|
||||
: stream.Readable.fromWeb(response.body);
|
||||
await stream.promises.pipeline(nodeStream, iconv.decodeStream(this.encoding), parser);
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - Stream processing failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, this.#createParseError(`Stream processing failed: ${error.message}`, error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the reload interval, but only if we need to increase the speed.
|
||||
* @param {number} interval - Interval for the update in milliseconds.
|
||||
*/
|
||||
setReloadInterval (interval) {
|
||||
if (interval > 1000 && interval < this.httpFetcher.reloadInterval) {
|
||||
this.httpFetcher.reloadInterval = interval;
|
||||
}
|
||||
}
|
||||
|
||||
startFetch () {
|
||||
this.httpFetcher.startPeriodicFetch();
|
||||
}
|
||||
|
||||
broadcastItems () {
|
||||
if (this.items.length <= 0) {
|
||||
Log.info("No items to broadcast yet.");
|
||||
return;
|
||||
}
|
||||
Log.info(`Broadcasting ${this.items.length} items.`);
|
||||
this.itemsReceivedCallback(this);
|
||||
}
|
||||
|
||||
/** @param {function(NewsfeedFetcher): void} callback - Called when items are received */
|
||||
onReceive (callback) {
|
||||
this.itemsReceivedCallback = callback;
|
||||
}
|
||||
|
||||
/** @param {function(NewsfeedFetcher, object): void} callback - Called on fetch error */
|
||||
onError (callback) {
|
||||
this.fetchFailedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NewsfeedFetcher;
|
||||
@@ -0,0 +1,99 @@
|
||||
const NodeHelper = require("node_helper");
|
||||
const Log = require("logger");
|
||||
const NewsfeedFetcher = require("./newsfeedfetcher");
|
||||
|
||||
module.exports = NodeHelper.create({
|
||||
// Override start method.
|
||||
start () {
|
||||
Log.log(`Starting node helper for: ${this.name}`);
|
||||
this.fetchers = [];
|
||||
},
|
||||
|
||||
// Override socketNotificationReceived received.
|
||||
socketNotificationReceived (notification, payload) {
|
||||
if (notification === "ADD_FEED") {
|
||||
this.createFetcher(payload.feed, payload.config);
|
||||
} else if (notification === "CHECK_ARTICLE_URL") {
|
||||
this.checkArticleUrl(payload.url);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks whether a URL can be displayed in an iframe by inspecting
|
||||
* X-Frame-Options and Content-Security-Policy headers server-side.
|
||||
* @param {string} url The article URL to check.
|
||||
*/
|
||||
async checkArticleUrl (url) {
|
||||
try {
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
const xfo = response.headers.get("x-frame-options");
|
||||
const csp = response.headers.get("content-security-policy");
|
||||
// sameorigin also blocks since the article is on a different origin than MM
|
||||
const blockedByXFO = xfo && ["deny", "sameorigin"].includes(xfo.toLowerCase().trim());
|
||||
const blockedByCSP = csp && (/frame-ancestors\s+['"]?none['"]?/).test(csp);
|
||||
this.sendSocketNotification("ARTICLE_URL_STATUS", { url, canFrame: !blockedByXFO && !blockedByCSP });
|
||||
} catch {
|
||||
// Network error or HEAD not supported — let the browser try the iframe anyway
|
||||
this.sendSocketNotification("ARTICLE_URL_STATUS", { url, canFrame: true });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (feed, config) {
|
||||
const url = feed.url || "";
|
||||
const encoding = feed.encoding || "UTF-8";
|
||||
const reloadInterval = feed.reloadInterval || config.reloadInterval || 5 * 60 * 1000;
|
||||
const useCorsProxy = feed.useCorsProxy ?? true;
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
Log.error("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, config.allowedBasicHtmlTags);
|
||||
|
||||
fetcher.onReceive(() => {
|
||||
this.broadcastFeeds();
|
||||
});
|
||||
|
||||
fetcher.onError((fetcher, errorInfo) => {
|
||||
Log.error("Error: Could not fetch newsfeed: ", fetcher.url, errorInfo.message || errorInfo);
|
||||
this.sendSocketNotification("NEWSFEED_ERROR", {
|
||||
error_type: errorInfo.translationKey
|
||||
});
|
||||
});
|
||||
|
||||
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 () {
|
||||
const feeds = {};
|
||||
for (const url in this.fetchers) {
|
||||
feeds[url] = this.fetchers[url].items;
|
||||
}
|
||||
this.sendSocketNotification("NEWS_ITEMS", feeds);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<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,218 @@
|
||||
const util = require("node:util");
|
||||
const execFile = util.promisify(require("node:child_process").execFile);
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const Log = require("logger");
|
||||
|
||||
class GitHelper {
|
||||
constructor () {
|
||||
this.gitRepos = [];
|
||||
this.gitResultList = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract commit range (<from>..<to>) for the current branch from `git fetch -n --dry-run` output.
|
||||
* Falls back to `<branch>..origin/<branch>` when no matching update line is found.
|
||||
* @param {string} fetchOutput fetch dry-run stderr output
|
||||
* @param {string} currentBranch currently checked local branch
|
||||
* @returns {string} commit range for rev-list
|
||||
*/
|
||||
getRefDiffFromFetchDryRun (fetchOutput, currentBranch) {
|
||||
const fallbackRefDiff = `${currentBranch}..origin/${currentBranch}`;
|
||||
|
||||
for (const line of fetchOutput.split("\n")) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
const [refDiff, branchName] = columns;
|
||||
|
||||
if (branchName === currentBranch && refDiff?.includes("..")) {
|
||||
return refDiff;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackRefDiff;
|
||||
}
|
||||
|
||||
async execGit (moduleFolder, ...args) {
|
||||
const { stdout = "", stderr = "" } = await execFile("git", args, { cwd: moduleFolder });
|
||||
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
async isGitRepo (moduleFolder) {
|
||||
const { stderr } = await this.execGit(moduleFolder, "remote", "-v");
|
||||
|
||||
if (stderr) {
|
||||
Log.error(`Failed to fetch git data for ${moduleFolder}: ${stderr}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async add (moduleName) {
|
||||
let moduleFolder = `${global.root_path}`;
|
||||
|
||||
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 {
|
||||
// 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.execGit(repo.folder, "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.execGit(repo.folder, "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;
|
||||
}
|
||||
|
||||
// Git writes fetch dry-run updates to stderr.
|
||||
const { stderr } = await this.execGit(repo.folder, "fetch", "-n", "--dry-run");
|
||||
|
||||
const refDiff = this.getRefDiffFromFetchDryRun(stderr, gitInfo.current);
|
||||
|
||||
// get behind with refs
|
||||
try {
|
||||
const { stdout } = await this.execGit(repo.folder, "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.execGit(repo.folder, "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.execGit(repo.folder, "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) {
|
||||
// Only log errors in non-test environments to keep test output clean
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.gitResultList;
|
||||
}
|
||||
|
||||
checkUpdates () {
|
||||
const updates = [];
|
||||
|
||||
for (const moduleInfo of this.gitResultList) {
|
||||
if (moduleInfo.behind > 0 && moduleInfo.module !== "MagicMirror") {
|
||||
Log.info(`Update found for module: ${moduleInfo.module}`);
|
||||
updates.push(moduleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GitHelper;
|
||||
@@ -0,0 +1,121 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const NodeHelper = require("node_helper");
|
||||
|
||||
const defaultModules = require(`${global.root_path}/${global.defaultModulesDir}/defaultmodules`);
|
||||
const GitHelper = require("./git_helper");
|
||||
const UpdateHelper = require("./update_helper");
|
||||
|
||||
const ONE_MINUTE = 60 * 1000;
|
||||
|
||||
module.exports = NodeHelper.create({
|
||||
config: {},
|
||||
|
||||
updateTimer: null,
|
||||
updateProcessStarted: false,
|
||||
|
||||
gitHelper: new GitHelper(),
|
||||
updateHelper: null,
|
||||
|
||||
getModules (modules) {
|
||||
if (this.config.useModulesFromConfig) {
|
||||
return modules;
|
||||
} else {
|
||||
// get modules from modules-directory
|
||||
const moduleDir = path.normalize(`${global.root_path}/modules`);
|
||||
const getDirectories = (source) => {
|
||||
return fs.readdirSync(source, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory() && dirent.name !== "default")
|
||||
.map((dirent) => dirent.name);
|
||||
};
|
||||
return getDirectories(moduleDir);
|
||||
}
|
||||
},
|
||||
|
||||
async configureModules (modules) {
|
||||
for (const moduleName of this.getModules(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;
|
||||
this.updateHelper = new UpdateHelper(this.config);
|
||||
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("REPO_STATUS", repo);
|
||||
}
|
||||
|
||||
const updates = await this.gitHelper.checkUpdates();
|
||||
|
||||
if (this.config.sendUpdatesNotifications && updates.length) {
|
||||
this.sendSocketNotification("UPDATES", updates);
|
||||
}
|
||||
|
||||
if (updates.length) {
|
||||
const updateResult = await this.updateHelper.parse(updates);
|
||||
for (const update of updateResult) {
|
||||
if (update.inProgress) {
|
||||
this.sendSocketNotification("UPDATE_STATUS", update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,187 @@
|
||||
const Exec = require("node:child_process").exec;
|
||||
const Spawn = require("node:child_process").spawn;
|
||||
|
||||
const Log = require("logger");
|
||||
|
||||
/*
|
||||
* class Updater
|
||||
* Allow to self updating 3rd party modules from command defined in config
|
||||
*
|
||||
* [constructor] read value in config:
|
||||
* updates: [ // array of modules update commands
|
||||
* {
|
||||
* <module name>: <update command>
|
||||
* },
|
||||
* {
|
||||
* ...
|
||||
* }
|
||||
* ],
|
||||
* updateTimeout: 2 * 60 * 1000, // max update duration
|
||||
* updateAutorestart: false // autoRestart MM when update done ?
|
||||
*
|
||||
* [main command]: parse(<Array of modules>):
|
||||
* parse if module update is needed
|
||||
* --> Apply ONLY one update (first of the module list)
|
||||
* --> auto-restart MagicMirror or wait manual restart by user
|
||||
* return array with modules update state information for `updatenotification` module displayer information
|
||||
* [
|
||||
* {
|
||||
* name = <module-name>, // name of the module
|
||||
* updateCommand = <update command>, // update command (if found)
|
||||
* inProgress = <boolean>, // an update if in progress for this module
|
||||
* error = <boolean>, // an error if detected when updating
|
||||
* updated = <boolean>, // updated successfully
|
||||
* needRestart = <boolean> // manual restart of MagicMirror is required by user
|
||||
* },
|
||||
* {
|
||||
* ...
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
|
||||
class Updater {
|
||||
constructor (config) {
|
||||
this.updates = config.updates;
|
||||
this.timeout = config.updateTimeout;
|
||||
this.autoRestart = config.updateAutorestart;
|
||||
this.moduleList = {};
|
||||
this.updating = false;
|
||||
this.version = global.version;
|
||||
this.root_path = global.root_path;
|
||||
Log.info("Updater Class Loaded!");
|
||||
}
|
||||
|
||||
// [main command] parse if module update is needed
|
||||
async parse (modules) {
|
||||
var parser = modules.map(async (module) => {
|
||||
if (this.moduleList[module.module] === undefined) {
|
||||
this.moduleList[module.module] = {};
|
||||
this.moduleList[module.module].name = module.module;
|
||||
this.moduleList[module.module].updateCommand = await this.applyCommand(module.module);
|
||||
this.moduleList[module.module].inProgress = false;
|
||||
this.moduleList[module.module].error = null;
|
||||
this.moduleList[module.module].updated = false;
|
||||
this.moduleList[module.module].needRestart = false;
|
||||
}
|
||||
if (!this.moduleList[module.module].inProgress) {
|
||||
if (!this.updating) {
|
||||
if (!this.moduleList[module.module].updateCommand) {
|
||||
this.updating = false;
|
||||
} else {
|
||||
this.updating = true;
|
||||
this.moduleList[module.module].inProgress = true;
|
||||
Object.assign(this.moduleList[module.module], await this.updateProcess(this.moduleList[module.module]));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(parser);
|
||||
let updater = Object.values(this.moduleList);
|
||||
Log.debug("Update Result:", updater);
|
||||
return updater;
|
||||
}
|
||||
|
||||
/*
|
||||
* module updater with his proper command
|
||||
* return object as result
|
||||
* {
|
||||
* error: <boolean>, // if error detected
|
||||
* updated: <boolean>, // if updated successfully
|
||||
* needRestart: <boolean> // if magicmirror restart required
|
||||
* };
|
||||
*/
|
||||
updateProcess (module) {
|
||||
let Result = {
|
||||
error: false,
|
||||
updated: false,
|
||||
needRestart: false
|
||||
};
|
||||
let Command = null;
|
||||
const Path = `${this.root_path}/modules/`;
|
||||
const modulePath = Path + module.name;
|
||||
|
||||
if (module.updateCommand) {
|
||||
Command = module.updateCommand;
|
||||
} else {
|
||||
Log.warn(`Update of ${module.name} is not supported.`);
|
||||
return Result;
|
||||
}
|
||||
Log.info(`Updating ${module.name}...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Exec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout) => {
|
||||
if (error) {
|
||||
Log.error(`exec error: ${error}`);
|
||||
Result.error = true;
|
||||
} else {
|
||||
Log.info(`Update logs of ${module.name}: ${stdout}`);
|
||||
Result.updated = true;
|
||||
if (this.autoRestart) {
|
||||
Log.info("Update done");
|
||||
setTimeout(() => this.nodeRestart(), 3000);
|
||||
} else {
|
||||
Log.info("Update done, don't forget to restart MagicMirror!");
|
||||
Result.needRestart = true;
|
||||
}
|
||||
}
|
||||
resolve(Result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the current MagicMirror process after a successful auto-update.
|
||||
* Under PM2 just exit and let PM2 respawn — spawning a child here would
|
||||
* race against PM2 and cause an EADDRINUSE conflict (see issue #4165).
|
||||
* Standalone: spawn a detached clone of the current process, then exit.
|
||||
*/
|
||||
nodeRestart () {
|
||||
Log.info("Restarting MagicMirror...");
|
||||
|
||||
const isManagedByPm2 = process.env.pm_id !== undefined;
|
||||
if (isManagedByPm2) {
|
||||
Log.info("Running under PM2 — exiting for PM2 to respawn.");
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
this._spawnDetachedSelf();
|
||||
process.exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached clone of the current Node process (same binary + argv),
|
||||
* wired to the parent's stdout/stderr.
|
||||
*/
|
||||
_spawnDetachedSelf () {
|
||||
const nodeBinary = process.argv[0];
|
||||
const nodeArgs = process.argv.slice(1);
|
||||
const spawnOptions = {
|
||||
cwd: this.root_path,
|
||||
detached: true,
|
||||
stdio: ["ignore", process.stdout, process.stderr]
|
||||
};
|
||||
|
||||
const child = Spawn(nodeBinary, nodeArgs, spawnOptions);
|
||||
child.unref();
|
||||
}
|
||||
|
||||
// check if module is MagicMirror
|
||||
isMagicMirror (module) {
|
||||
if (module === "MagicMirror") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// search update module command
|
||||
applyCommand (module) {
|
||||
if (this.isMagicMirror(module.module) || !this.updates.length) return null;
|
||||
let command = null;
|
||||
this.updates.forEach((updater) => {
|
||||
if (updater[module]) command = updater[module];
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Updater;
|
||||
@@ -0,0 +1,3 @@
|
||||
.module.updatenotification a.difflink {
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
Module.register("updatenotification", {
|
||||
defaults: {
|
||||
updateInterval: 10 * 60 * 1000, // every 10 minutes
|
||||
refreshInterval: 24 * 60 * 60 * 1000, // one day
|
||||
ignoreModules: [],
|
||||
sendUpdatesNotifications: false,
|
||||
updates: [],
|
||||
updateTimeout: 2 * 60 * 1000, // max update duration
|
||||
updateAutorestart: false, // autoRestart MM when update done ?
|
||||
useModulesFromConfig: true // if `false` iterate over modules directory
|
||||
},
|
||||
|
||||
suspended: false,
|
||||
moduleList: {},
|
||||
needRestart: false,
|
||||
updates: [],
|
||||
|
||||
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 "REPO_STATUS":
|
||||
this.updateUI(payload);
|
||||
break;
|
||||
case "UPDATES":
|
||||
this.sendNotification("UPDATES", payload);
|
||||
break;
|
||||
case "UPDATE_STATUS":
|
||||
this.updatesNotifier(payload);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
getStyles () {
|
||||
return [`${this.name}.css`];
|
||||
},
|
||||
|
||||
getTemplate () {
|
||||
return `${this.name}.njk`;
|
||||
},
|
||||
|
||||
getTemplateData () {
|
||||
return { moduleList: this.moduleList, updatesList: this.updates, suspended: this.suspended, needRestart: this.needRestart };
|
||||
},
|
||||
|
||||
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/MagicMirrorOrg/MagicMirror/compare/${localRef}...${remoteRef}" class="xsmall dimmed difflink" target="_blank">${text}</a>`;
|
||||
});
|
||||
},
|
||||
|
||||
updatesNotifier (payload, done = true) {
|
||||
if (this.updates[payload.name] === undefined) {
|
||||
this.updates[payload.name] = {
|
||||
name: payload.name,
|
||||
done: done
|
||||
};
|
||||
|
||||
if (payload.error) {
|
||||
this.sendSocketNotification("UPDATE_ERROR", payload.name);
|
||||
this.updates[payload.name].done = false;
|
||||
} else {
|
||||
if (payload.updated) {
|
||||
delete this.moduleList[payload.name];
|
||||
this.updates[payload.name].done = true;
|
||||
}
|
||||
if (payload.needRestart) {
|
||||
this.needRestart = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDom(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{% if not suspended %}
|
||||
{% if needRestart %}
|
||||
<div class="small bright">
|
||||
<i class="fas fa-rotate"></i>
|
||||
<span>
|
||||
{% set restartTextLabel = "UPDATE_NOTIFICATION_NEED-RESTART" %}
|
||||
{{ restartTextLabel | translate() | safe }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% for name, status in updatesList %}
|
||||
<div class="small bright">
|
||||
{% if status.done %}
|
||||
<i class="fas fa-check" style="color: LightGreen;"></i>
|
||||
<span>
|
||||
{% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %}
|
||||
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
|
||||
</span>
|
||||
{% else %}
|
||||
<i class="fas fa-xmark" style="color: red;"></i>
|
||||
<span>
|
||||
{% set updateTextLabel = "UPDATE_NOTIFICATION_ERROR" %}
|
||||
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 = {
|
||||
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,97 @@
|
||||
{% macro humidity() %}
|
||||
{% if current.humidity %}
|
||||
<span class="humidity"
|
||||
><span>{{ current.humidity | decimalSymbol }}</span><sup> <i class="wi wi-humidity humidity-icon"></i></sup
|
||||
></span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% 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 === "wind" %}
|
||||
{{ humidity() }}
|
||||
{% endif %}
|
||||
{% if config.showSun and current.nextSunAction() %}
|
||||
<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.uvIndex }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex large type-temp">
|
||||
{% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}
|
||||
<span class="medium fas fa-home"></span>
|
||||
<span style="display: inline-block">
|
||||
{% if config.showIndoorTemperature and indoor.temperature %}
|
||||
<sup class="small" style="position: relative; display: block; text-align: left;">
|
||||
<span> {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} </span>
|
||||
</sup>
|
||||
{% endif %}
|
||||
{% if config.showIndoorHumidity and indoor.humidity %}
|
||||
<sub class="small" style="position: relative; display: block; text-align: left;">
|
||||
<span> {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} </span>
|
||||
</sub>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if current.weatherType %}
|
||||
<span class="light wi weathericon wi-{{ current.weatherType }}"></span>
|
||||
{% endif %}
|
||||
<span class="light bright">{{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}</span>
|
||||
{% if config.showHumidity === "temp" %}
|
||||
<span class="medium bright">{{ humidity() }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}
|
||||
<div class="normal medium feelslike">
|
||||
{% if config.showFeelsLike %}
|
||||
<span class="dimmed">
|
||||
{% if config.showHumidity === "feelslike" %}
|
||||
{{ humidity() }}
|
||||
{% endif %}
|
||||
{{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }}
|
||||
</span>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationAmount and current.precipitationAmount is defined and current.precipitationAmount is not none %}
|
||||
<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 is defined and current.precipitationProbability is not none %}
|
||||
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}% </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if config.showHumidity === "below" %}
|
||||
<span class="medium dimmed">{{ humidity() }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<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,46 @@
|
||||
{% 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(config.forecastDateFormat) }}</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.uvIndex }}
|
||||
<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,48 @@
|
||||
{% 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.uvIndex!=0 %}
|
||||
{{ hour.uvIndex }}
|
||||
<span class="wi weathericon wi-hot"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showHumidity != "none" %}
|
||||
<td class="align-left bright humidity-hourly">
|
||||
{{ hour.humidity }}
|
||||
<span class="wi wi-humidity humidity-icon"></span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationAmount %}
|
||||
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
|
||||
<td class="align-right bright precipitation-amount">{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationProbability %}
|
||||
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
|
||||
<td class="align-right bright precipitation-prob">{{ hour.precipitationProbability | unit('precip', '%') }}</td>
|
||||
{% endif %}
|
||||
{% 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,112 @@
|
||||
const path = require("node:path");
|
||||
const NodeHelper = require("node_helper");
|
||||
const Log = require("logger");
|
||||
|
||||
module.exports = NodeHelper.create({
|
||||
providers: {},
|
||||
lastData: {},
|
||||
|
||||
start () {
|
||||
Log.log(`Starting node helper for: ${this.name}`);
|
||||
},
|
||||
|
||||
socketNotificationReceived (notification, payload) {
|
||||
if (notification === "INIT_WEATHER") {
|
||||
Log.log(`Received INIT_WEATHER for instance ${payload.instanceId}`);
|
||||
this.initWeatherProvider(payload);
|
||||
} else if (notification === "STOP_WEATHER") {
|
||||
Log.log(`Received STOP_WEATHER for instance ${payload.instanceId}`);
|
||||
this.stopWeatherProvider(payload.instanceId);
|
||||
}
|
||||
// FETCH_WEATHER is no longer needed - HTTPFetcher handles periodic fetching
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize a weather provider
|
||||
* @param {object} config The configuration object
|
||||
*/
|
||||
async initWeatherProvider (config) {
|
||||
const identifier = config.weatherProvider.toLowerCase();
|
||||
const instanceId = config.instanceId;
|
||||
|
||||
Log.log(`Attempting to initialize provider ${identifier} for instance ${instanceId}`);
|
||||
|
||||
if (this.providers[instanceId]) {
|
||||
Log.log(`Weather provider ${identifier} already initialized for instance ${instanceId}, re-sending WEATHER_INITIALIZED`);
|
||||
// Client may have restarted (e.g. page reload) - re-send so it recovers location name
|
||||
this.sendSocketNotification("WEATHER_INITIALIZED", {
|
||||
instanceId,
|
||||
locationName: this.providers[instanceId].locationName
|
||||
});
|
||||
// Push cached data immediately so reconnecting clients don't wait for next scheduled fetch
|
||||
if (this.lastData[instanceId]) {
|
||||
this.sendSocketNotification("WEATHER_DATA", this.lastData[instanceId]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamically load the provider module
|
||||
const providerPath = path.join(__dirname, "providers", `${identifier}.js`);
|
||||
Log.log(`Loading provider from: ${providerPath}`);
|
||||
const ProviderClass = require(providerPath);
|
||||
|
||||
// Create provider instance
|
||||
const provider = new ProviderClass(config);
|
||||
|
||||
// Set up callbacks before initializing
|
||||
provider.setCallbacks(
|
||||
(data) => {
|
||||
// On data received
|
||||
const payload = { instanceId, type: config.type, data };
|
||||
this.lastData[instanceId] = payload;
|
||||
this.sendSocketNotification("WEATHER_DATA", payload);
|
||||
},
|
||||
(errorInfo) => {
|
||||
// On error
|
||||
this.sendSocketNotification("WEATHER_ERROR", {
|
||||
instanceId,
|
||||
error: errorInfo.message || "Unknown error",
|
||||
translationKey: errorInfo.translationKey
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await provider.initialize();
|
||||
this.providers[instanceId] = provider;
|
||||
|
||||
this.sendSocketNotification("WEATHER_INITIALIZED", {
|
||||
instanceId,
|
||||
locationName: provider.locationName
|
||||
});
|
||||
|
||||
// Start periodic fetching
|
||||
provider.start();
|
||||
|
||||
Log.log(`Weather provider ${identifier} initialized for instance ${instanceId}`);
|
||||
} catch (error) {
|
||||
Log.error(`Failed to initialize weather provider ${identifier}:`, error);
|
||||
this.sendSocketNotification("WEATHER_ERROR", {
|
||||
instanceId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop and cleanup a weather provider
|
||||
* @param {string} instanceId The instance identifier
|
||||
*/
|
||||
stopWeatherProvider (instanceId) {
|
||||
const provider = this.providers[instanceId];
|
||||
|
||||
if (provider) {
|
||||
Log.log(`Stopping weather provider for instance ${instanceId}`);
|
||||
provider.stop();
|
||||
delete this.providers[instanceId];
|
||||
delete this.lastData[instanceId];
|
||||
} else {
|
||||
Log.warn(`No provider found for instance ${instanceId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Shared utility functions for weather providers
|
||||
*/
|
||||
|
||||
const SunCalc = require("suncalc");
|
||||
|
||||
/**
|
||||
* Convert OpenWeatherMap icon codes to internal weather types
|
||||
* @param {string} weatherType - OpenWeatherMap icon code (e.g., "01d", "02n")
|
||||
* @returns {string|null} Internal weather type
|
||||
*/
|
||||
function 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply timezone offset to a date
|
||||
* @param {Date} date - The date to apply offset to
|
||||
* @param {number} offsetMinutes - Timezone offset in minutes
|
||||
* @returns {Date} Date with applied offset
|
||||
*/
|
||||
function applyTimezoneOffset (date, offsetMinutes) {
|
||||
const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000);
|
||||
return new Date(utcTime + (offsetMinutes * 60000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit decimal places for coordinates (truncate, not round)
|
||||
* @param {number} value - The coordinate value
|
||||
* @param {number} decimals - Maximum number of decimal places
|
||||
* @returns {number} Value with limited decimal places
|
||||
*/
|
||||
function limitDecimals (value, decimals) {
|
||||
const str = value.toString();
|
||||
if (str.includes(".")) {
|
||||
const parts = str.split(".");
|
||||
if (parts[1].length > decimals) {
|
||||
return parseFloat(`${parts[0]}.${parts[1].substring(0, decimals)}`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sunrise and sunset times for a given date and location
|
||||
* @param {Date} date - The date to calculate for
|
||||
* @param {number} lat - Latitude
|
||||
* @param {number} lon - Longitude
|
||||
* @returns {object} Object with sunrise and sunset Date objects
|
||||
*/
|
||||
function getSunTimes (date, lat, lon) {
|
||||
const sunTimes = SunCalc.getTimes(date, lat, lon);
|
||||
return {
|
||||
sunrise: sunTimes.sunrise,
|
||||
sunset: sunTimes.sunset
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given time is during daylight hours
|
||||
* @param {Date} date - The date/time to check
|
||||
* @param {Date} sunrise - Sunrise time
|
||||
* @param {Date} sunset - Sunset time
|
||||
* @returns {boolean} True if during daylight hours
|
||||
*/
|
||||
function isDayTime (date, sunrise, sunset) {
|
||||
if (!sunrise || !sunset) {
|
||||
return true; // Default to day if times unavailable
|
||||
}
|
||||
return date >= sunrise && date < sunset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timezone offset as string (e.g., "+01:00", "-05:30")
|
||||
* @param {number} offsetMinutes - Timezone offset in minutes (use -new Date().getTimezoneOffset() for local)
|
||||
* @returns {string} Formatted offset string
|
||||
*/
|
||||
function formatTimezoneOffset (offsetMinutes) {
|
||||
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||
const minutes = Math.abs(offsetMinutes) % 60;
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
return `${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date string in YYYY-MM-DD format (local time)
|
||||
* @param {Date} date - The date to format
|
||||
* @returns {string} Date string in YYYY-MM-DD format
|
||||
*/
|
||||
function getDateString (date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wind speed from km/h to m/s
|
||||
* @param {number} kmh - Wind speed in km/h
|
||||
* @returns {number} Wind speed in m/s
|
||||
*/
|
||||
function convertKmhToMs (kmh) {
|
||||
return kmh / 3.6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert cardinal wind direction string to degrees
|
||||
* @param {string} direction - Cardinal direction (e.g., "N", "NNE", "SW")
|
||||
* @returns {number|null} Direction in degrees (0-360) or null if unknown
|
||||
*/
|
||||
function cardinalToDegrees (direction) {
|
||||
const directions = {
|
||||
N: 0,
|
||||
NNE: 22.5,
|
||||
NE: 45,
|
||||
ENE: 67.5,
|
||||
E: 90,
|
||||
ESE: 112.5,
|
||||
SE: 135,
|
||||
SSE: 157.5,
|
||||
S: 180,
|
||||
SSW: 202.5,
|
||||
SW: 225,
|
||||
WSW: 247.5,
|
||||
W: 270,
|
||||
WNW: 292.5,
|
||||
NW: 315,
|
||||
NNW: 337.5
|
||||
};
|
||||
return directions[direction] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and limit coordinate precision
|
||||
* @param {object} config - Configuration object with lat/lon properties
|
||||
* @param {number} maxDecimals - Maximum decimal places to preserve
|
||||
* @throws {Error} If coordinates are missing or invalid
|
||||
*/
|
||||
function validateCoordinates (config, maxDecimals = 4) {
|
||||
if (config.lat == null || config.lon == null
|
||||
|| !Number.isFinite(config.lat) || !Number.isFinite(config.lon)) {
|
||||
throw new Error("Latitude and longitude are required");
|
||||
}
|
||||
|
||||
config.lat = limitDecimals(config.lat, maxDecimals);
|
||||
config.lon = limitDecimals(config.lon, maxDecimals);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
convertWeatherType,
|
||||
applyTimezoneOffset,
|
||||
limitDecimals,
|
||||
getSunTimes,
|
||||
isDayTime,
|
||||
formatTimezoneOffset,
|
||||
getDateString,
|
||||
convertKmhToMs,
|
||||
cardinalToDegrees,
|
||||
validateCoordinates
|
||||
};
|
||||
@@ -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/module-development/weather-provider.html).
|
||||
@@ -0,0 +1,349 @@
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
const BUIENRADAR_API_BASE = "https://forecast.buienradar.nl/2.0/forecast";
|
||||
const ERROR_TRANSLATION_KEY = "MODULE_ERROR_UNSPECIFIED";
|
||||
const TIMESTAMP_HAS_TIME_ZONE = /[zZ]|[+-]\d{2}:?\d{2}$/;
|
||||
|
||||
// Mapping from Buienradar icon code to weather-icons class names (https://erikflowers.github.io/weather-icons/).
|
||||
// Icon filenames use the Buienradar code: https://cdn.buienradar.nl/resources/images/icons/weather/30x30/a.png
|
||||
const WEATHER_ICON_MAP = {
|
||||
a: "day-sunny",
|
||||
aa: "night-clear",
|
||||
b: "day-cloudy",
|
||||
bb: "night-alt-cloudy",
|
||||
c: "cloudy",
|
||||
cc: "cloudy",
|
||||
d: "day-fog",
|
||||
dd: "night-fog",
|
||||
f: "day-sprinkle",
|
||||
ff: "night-alt-sprinkle",
|
||||
g: "day-storm-showers",
|
||||
gg: "night-alt-storm-showers",
|
||||
h: "day-rain",
|
||||
hh: "night-alt-rain",
|
||||
i: "day-rain-mix",
|
||||
ii: "night-alt-rain-mix",
|
||||
j: "day-cloudy",
|
||||
jj: "night-alt-cloudy",
|
||||
k: "day-showers",
|
||||
kk: "night-alt-showers",
|
||||
l: "showers",
|
||||
ll: "showers",
|
||||
m: "sprinkle",
|
||||
mm: "sprinkle",
|
||||
n: "day-haze",
|
||||
nn: "night-fog",
|
||||
o: "day-cloudy",
|
||||
oo: "night-alt-cloudy",
|
||||
p: "cloudy",
|
||||
pp: "cloudy",
|
||||
q: "showers",
|
||||
qq: "showers",
|
||||
r: "day-cloudy",
|
||||
rr: "night-alt-cloudy",
|
||||
s: "thunderstorm",
|
||||
ss: "thunderstorm",
|
||||
t: "snow",
|
||||
tt: "snow",
|
||||
u: "day-snow",
|
||||
uu: "night-alt-snow",
|
||||
v: "snow",
|
||||
vv: "snow",
|
||||
w: "rain-mix",
|
||||
ww: "rain-mix"
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Buienradar
|
||||
* Netherlands/Belgium only, metric system, no API key required
|
||||
* see https://buienradar.nl
|
||||
*/
|
||||
class BuienradarProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: BUIENRADAR_API_BASE,
|
||||
locationId: null,
|
||||
type: "current",
|
||||
maxEntries: 5,
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.locationName = null;
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
if (!this.config.locationId) {
|
||||
Log.error("[buienradar] No locationId configured");
|
||||
this.#sendErrorCallback("Buienradar locationId required. See https://www.buienradar.nl/overbuienradar/gratis-weerdata");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
this.fetcher = new HTTPFetcher(this.#getUrl(), {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
logContext: "weatherprovider.buienradar"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[buienradar] Failed to parse JSON:", error);
|
||||
this.#sendErrorCallback("Failed to parse API response");
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
try {
|
||||
if (!Array.isArray(data?.days) || data.days.length === 0) {
|
||||
throw new Error("Invalid API response");
|
||||
}
|
||||
|
||||
this.#setLocationName(data.location);
|
||||
|
||||
let weatherData;
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrentWeather(data.days[0]);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateDailyForecast(data.days);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourlyForecast(data.days);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (this.onDataCallback && weatherData) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[buienradar] Error processing weather data:", error);
|
||||
this.#sendErrorCallback(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#sendErrorCallback (message) {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message,
|
||||
translationKey: ERROR_TRANSLATION_KEY
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#setLocationName (location) {
|
||||
if (location?.name) {
|
||||
this.locationName = location.name;
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrentWeather (day) {
|
||||
const closestHour = this.#getClosestHour(day.hours ?? []);
|
||||
const weather = this.#parseHour(closestHour);
|
||||
|
||||
const sunrise = this.#parseDate(day.sunrise);
|
||||
if (sunrise) weather.sunrise = sunrise;
|
||||
|
||||
const sunset = this.#parseDate(day.sunset);
|
||||
if (sunset) weather.sunset = sunset;
|
||||
|
||||
const minTemperature = this.#parseNumber(day.mintemp);
|
||||
if (minTemperature !== null) weather.minTemperature = minTemperature;
|
||||
|
||||
const maxTemperature = this.#parseNumber(day.maxtemp);
|
||||
if (maxTemperature !== null) weather.maxTemperature = maxTemperature;
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
#generateDailyForecast (days) {
|
||||
return days
|
||||
.slice(0, this.config.maxEntries)
|
||||
.map((day) => this.#parseDay(day));
|
||||
}
|
||||
|
||||
#generateHourlyForecast (days) {
|
||||
const hours = [];
|
||||
|
||||
for (const day of days) {
|
||||
for (const hour of day.hours ?? []) {
|
||||
hours.push(this.#parseHour(hour));
|
||||
if (hours.length >= this.config.maxEntries) {
|
||||
return hours;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#parseDay (day) {
|
||||
const weather = {};
|
||||
|
||||
const date = this.#parseDate(day.date);
|
||||
if (date) weather.date = date;
|
||||
|
||||
const minTemperature = this.#parseNumber(day.mintemp);
|
||||
if (minTemperature !== null) weather.minTemperature = minTemperature;
|
||||
|
||||
const maxTemperature = this.#parseNumber(day.maxtemp);
|
||||
if (maxTemperature !== null) weather.maxTemperature = maxTemperature;
|
||||
|
||||
const humidity = this.#parseNumber(day.humidity);
|
||||
if (humidity !== null) weather.humidity = humidity;
|
||||
|
||||
const windSpeed = this.#parseNumber(day.windspeedms);
|
||||
if (windSpeed !== null) weather.windSpeed = windSpeed;
|
||||
|
||||
const windFromDirection = this.#parseNumber(day.winddirectiondegrees);
|
||||
if (windFromDirection !== null) weather.windFromDirection = windFromDirection;
|
||||
|
||||
this.#applyPrecipitation(weather, day);
|
||||
|
||||
const sunrise = this.#parseDate(day.sunrise);
|
||||
if (sunrise) weather.sunrise = sunrise;
|
||||
|
||||
const sunset = this.#parseDate(day.sunset);
|
||||
if (sunset) weather.sunset = sunset;
|
||||
|
||||
weather.weatherType = this.#convertWeatherType(day.iconcode);
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
#parseHour (hour) {
|
||||
const weather = {};
|
||||
|
||||
const date = this.#parseDate(hour.datetime);
|
||||
if (date) weather.date = date;
|
||||
|
||||
const temperature = this.#parseNumber(hour.temperature);
|
||||
if (temperature !== null) weather.temperature = temperature;
|
||||
|
||||
const feelsLikeTemp = this.#parseNumber(hour.feeltemperature);
|
||||
if (feelsLikeTemp !== null) weather.feelsLikeTemp = feelsLikeTemp;
|
||||
|
||||
const humidity = this.#parseNumber(hour.humidity);
|
||||
if (humidity !== null) weather.humidity = humidity;
|
||||
|
||||
const windSpeed = this.#parseNumber(hour.windspeedms);
|
||||
if (windSpeed !== null) weather.windSpeed = windSpeed;
|
||||
|
||||
const windFromDirection = this.#parseNumber(hour.winddirectiondegrees);
|
||||
if (windFromDirection !== null) weather.windFromDirection = windFromDirection;
|
||||
|
||||
this.#applyPrecipitation(weather, hour);
|
||||
|
||||
weather.weatherType = this.#convertWeatherType(hour.iconcode);
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
#applyPrecipitation (weather, source) {
|
||||
const precipitationAmount = this.#parseNumber(source.precipitationmm);
|
||||
if (precipitationAmount !== null) {
|
||||
weather.precipitationAmount = precipitationAmount;
|
||||
weather.precipitationUnits = "mm";
|
||||
}
|
||||
|
||||
const precipitationProbability = this.#parseNumber(source.precipitation);
|
||||
if (precipitationProbability !== null) {
|
||||
weather.precipitationProbability = precipitationProbability;
|
||||
}
|
||||
}
|
||||
|
||||
#getClosestHour (hours) {
|
||||
if (hours.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let closest = hours[0];
|
||||
let closestDiff = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const hour of hours) {
|
||||
const date = this.#parseDate(hour.datetime);
|
||||
if (!date) continue;
|
||||
|
||||
const diff = Math.abs(date.getTime() - now);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closest = hour;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = `${now.getUTCMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${now.getUTCDate()}`.padStart(2, "0");
|
||||
const hours = `${now.getUTCHours()}`.padStart(2, "0");
|
||||
const minutes = `${now.getUTCMinutes()}`.padStart(2, "0");
|
||||
const cacheBust = `${year}${month}${day}${hours}${minutes}`;
|
||||
const params = new URLSearchParams({ btc: cacheBust });
|
||||
|
||||
return `${this.config.apiBase}/${this.config.locationId}?${params}`;
|
||||
}
|
||||
|
||||
#parseDate (value) {
|
||||
if (!value) return null;
|
||||
|
||||
const text = `${value}`;
|
||||
const date = new Date(TIMESTAMP_HAS_TIME_ZONE.test(text) ? text : `${text}Z`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
#parseNumber (value) {
|
||||
const number = parseFloat(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
#convertWeatherType (icon) {
|
||||
if (!icon) return null;
|
||||
return WEATHER_ICON_MAP[`${icon}`.toLowerCase()] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BuienradarProvider;
|
||||
@@ -0,0 +1,451 @@
|
||||
const Log = require("logger");
|
||||
const { convertKmhToMs } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Environment Canada MSC Datamart
|
||||
* Canada only, no API key required (anonymous access)
|
||||
*
|
||||
* Documentation:
|
||||
* https://dd.weather.gc.ca/citypage_weather/schema/
|
||||
* https://eccc-msc.github.io/open-data/msc-datamart/readme_en/
|
||||
*
|
||||
* Requires siteCode and provCode config parameters
|
||||
* See https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv
|
||||
*/
|
||||
class EnvCanadaProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
siteCode: "s0000000",
|
||||
provCode: "ON",
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
this.lastCityPageURL = null;
|
||||
this.cacheCurrentTemp = null;
|
||||
this.currentHour = null; // Track current hour for URL updates
|
||||
}
|
||||
|
||||
initialize () {
|
||||
this.#validateConfig();
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
#validateConfig () {
|
||||
if (!this.config.siteCode || !this.config.provCode) {
|
||||
throw new Error("siteCode and provCode are required");
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
this.currentHour = new Date().toISOString().substring(11, 13);
|
||||
const indexURL = this.#getIndexUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(indexURL, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
logContext: "weatherprovider.envcanada"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
// Check if hour changed - restart fetcher with new URL
|
||||
const newHour = new Date().toISOString().substring(11, 13);
|
||||
if (newHour !== this.currentHour) {
|
||||
Log.info("[envcanada] Hour changed, reinitializing fetcher");
|
||||
this.stop();
|
||||
this.#initializeFetcher();
|
||||
this.start();
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const cityPageURL = this.#extractCityPageURL(html);
|
||||
|
||||
if (!cityPageURL) {
|
||||
// This can happen during hour transitions when old responses arrive
|
||||
Log.debug("[envcanada] Could not find city page URL (may be stale response from previous hour)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cityPageURL === this.lastCityPageURL) {
|
||||
Log.debug("[envcanada] City page unchanged");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastCityPageURL = cityPageURL;
|
||||
await this.#fetchCityPage(cityPageURL);
|
||||
|
||||
} catch (error) {
|
||||
Log.error("[envcanada] Error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #fetchCityPage (url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const xml = await response.text();
|
||||
const weatherData = this.#parseWeatherData(xml);
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[envcanada] Fetch city page error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to fetch city data",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#parseWeatherData (xml) {
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
return this.#generateCurrentWeather(xml);
|
||||
case "forecast":
|
||||
case "daily":
|
||||
return this.#generateForecast(xml);
|
||||
case "hourly":
|
||||
return this.#generateHourly(xml);
|
||||
default:
|
||||
Log.error(`[envcanada] Unknown weather type: ${this.config.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrentWeather (xml) {
|
||||
const current = { date: new Date() };
|
||||
|
||||
// Try to get temperature from currentConditions first
|
||||
const currentTempStr = this.#extract(xml, /<currentConditions>.*?<temperature[^>]*>(.*?)<\/temperature>/s);
|
||||
|
||||
if (currentTempStr && currentTempStr !== "") {
|
||||
current.temperature = parseFloat(currentTempStr);
|
||||
this.cacheCurrentTemp = current.temperature;
|
||||
} else {
|
||||
// Fallback: extract from first forecast period if currentConditions is empty
|
||||
const firstForecast = xml.match(/<forecast>(.*?)<\/forecast>/s);
|
||||
if (firstForecast) {
|
||||
const forecastXml = firstForecast[1];
|
||||
const temp = this.#extract(forecastXml, /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp && temp !== "") {
|
||||
current.temperature = parseFloat(temp);
|
||||
this.cacheCurrentTemp = current.temperature;
|
||||
} else if (this.cacheCurrentTemp !== null) {
|
||||
current.temperature = this.cacheCurrentTemp;
|
||||
} else {
|
||||
current.temperature = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wind chill / humidex for feels like temperature
|
||||
const windChill = this.#extract(xml, /<windChill[^>]*>(.*?)<\/windChill>/);
|
||||
const humidex = this.#extract(xml, /<humidex[^>]*>(.*?)<\/humidex>/);
|
||||
if (windChill) {
|
||||
current.feelsLikeTemp = parseFloat(windChill);
|
||||
} else if (humidex) {
|
||||
current.feelsLikeTemp = parseFloat(humidex);
|
||||
}
|
||||
|
||||
// Get wind and icon from currentConditions or first forecast
|
||||
const firstForecast = xml.match(/<forecast>(.*?)<\/forecast>/s);
|
||||
if (!firstForecast) {
|
||||
Log.warn("[envcanada] No forecast data available");
|
||||
return current;
|
||||
}
|
||||
|
||||
const forecastXml = firstForecast[1];
|
||||
|
||||
// Wind speed - try currentConditions first, fallback to forecast
|
||||
let windSpeed = this.#extract(xml, /<currentConditions>.*?<wind>.*?<speed[^>]*>(.*?)<\/speed>/s);
|
||||
if (!windSpeed) {
|
||||
windSpeed = this.#extract(forecastXml, /<speed[^>]*>(.*?)<\/speed>/);
|
||||
}
|
||||
if (windSpeed) {
|
||||
current.windSpeed = (windSpeed === "calm") ? 0 : convertKmhToMs(parseFloat(windSpeed));
|
||||
}
|
||||
|
||||
// Wind bearing - try currentConditions first, fallback to forecast
|
||||
let windBearing = this.#extract(xml, /<currentConditions>.*?<wind>.*?<bearing[^>]*>(.*?)<\/bearing>/s);
|
||||
if (!windBearing) {
|
||||
windBearing = this.#extract(forecastXml, /<bearing[^>]*>(.*?)<\/bearing>/);
|
||||
}
|
||||
if (windBearing) current.windFromDirection = parseFloat(windBearing);
|
||||
|
||||
// Try icon from currentConditions first, fallback to forecast
|
||||
let iconCode = this.#extract(xml, /<currentConditions>.*?<iconCode[^>]*>(.*?)<\/iconCode>/s);
|
||||
if (!iconCode) {
|
||||
iconCode = this.#extract(forecastXml, /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
}
|
||||
if (iconCode) current.weatherType = this.#convertWeatherType(iconCode);
|
||||
|
||||
// Humidity from currentConditions
|
||||
const humidity = this.#extract(xml, /<currentConditions>.*?<relativeHumidity[^>]*>(.*?)<\/relativeHumidity>/s);
|
||||
if (humidity) current.humidity = parseFloat(humidity);
|
||||
|
||||
// Precipitation probability from forecast
|
||||
const pop = this.#extract(forecastXml, /<pop[^>]*>(.*?)<\/pop>/);
|
||||
if (pop && pop !== "") {
|
||||
current.precipitationProbability = parseFloat(pop);
|
||||
}
|
||||
|
||||
// Sunrise/sunset (from riseSet, independent of currentConditions)
|
||||
const sunriseTime = this.#extract(xml, /<dateTime[^>]*name="sunrise"[^>]*>.*?<timeStamp>(.*?)<\/timeStamp>/s);
|
||||
const sunsetTime = this.#extract(xml, /<dateTime[^>]*name="sunset"[^>]*>.*?<timeStamp>(.*?)<\/timeStamp>/s);
|
||||
if (sunriseTime) current.sunrise = this.#parseECTime(sunriseTime);
|
||||
if (sunsetTime) current.sunset = this.#parseECTime(sunsetTime);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateForecast (xml) {
|
||||
const days = [];
|
||||
const forecasts = xml.match(/<forecast>(.*?)<\/forecast>/gs) || [];
|
||||
|
||||
if (forecasts.length === 0) return days;
|
||||
|
||||
// Get current temp
|
||||
const currentTempStr = this.#extract(xml, /<currentConditions>.*?<temperature[^>]*>(.*?)<\/temperature>/s);
|
||||
const currentTemp = currentTempStr ? parseFloat(currentTempStr) : null;
|
||||
|
||||
// Check if first forecast is Today or Tonight
|
||||
const isToday = forecasts[0].includes("textForecastName=\"Today\"");
|
||||
|
||||
let nextDay = isToday ? 2 : 1;
|
||||
const lastDay = isToday ? 12 : 11;
|
||||
|
||||
// Process first day
|
||||
const firstDay = {
|
||||
date: new Date(),
|
||||
precipitationProbability: null
|
||||
};
|
||||
this.#extractForecastTemps(firstDay, forecasts, 0, isToday, currentTemp);
|
||||
this.#extractForecastPrecip(firstDay, forecasts, 0);
|
||||
const firstIcon = this.#extract(forecasts[0], /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
if (firstIcon) firstDay.weatherType = this.#convertWeatherType(firstIcon);
|
||||
days.push(firstDay);
|
||||
|
||||
// Process remaining days
|
||||
let date = new Date();
|
||||
for (let i = nextDay; i < lastDay && i < forecasts.length; i += 2) {
|
||||
date = new Date(date);
|
||||
date.setDate(date.getDate() + 1);
|
||||
|
||||
const day = {
|
||||
date: new Date(date),
|
||||
precipitationProbability: null
|
||||
};
|
||||
this.#extractForecastTemps(day, forecasts, i, true, currentTemp);
|
||||
this.#extractForecastPrecip(day, forecasts, i);
|
||||
const icon = this.#extract(forecasts[i], /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
if (icon) day.weatherType = this.#convertWeatherType(icon);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#extractForecastTemps (weather, forecasts, index, hasToday, currentTemp) {
|
||||
let tempToday = null;
|
||||
let tempTonight = null;
|
||||
|
||||
if (hasToday && forecasts[index]) {
|
||||
const temp = this.#extract(forecasts[index], /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp) tempToday = parseFloat(temp);
|
||||
}
|
||||
|
||||
if (forecasts[index + 1]) {
|
||||
const temp = this.#extract(forecasts[index + 1], /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp) tempTonight = parseFloat(temp);
|
||||
}
|
||||
|
||||
if (tempToday !== null && tempTonight !== null) {
|
||||
weather.maxTemperature = Math.max(tempToday, tempTonight);
|
||||
weather.minTemperature = Math.min(tempToday, tempTonight);
|
||||
} else if (tempToday !== null) {
|
||||
weather.maxTemperature = tempToday;
|
||||
weather.minTemperature = currentTemp || tempToday;
|
||||
} else if (tempTonight !== null) {
|
||||
weather.maxTemperature = currentTemp || tempTonight;
|
||||
weather.minTemperature = tempTonight;
|
||||
}
|
||||
}
|
||||
|
||||
#extractForecastPrecip (weather, forecasts, index) {
|
||||
const precips = [];
|
||||
|
||||
if (forecasts[index]) {
|
||||
const pop = this.#extract(forecasts[index], /<pop[^>]*>(.*?)<\/pop>/);
|
||||
if (pop) precips.push(parseFloat(pop));
|
||||
}
|
||||
|
||||
if (forecasts[index + 1]) {
|
||||
const pop = this.#extract(forecasts[index + 1], /<pop[^>]*>(.*?)<\/pop>/);
|
||||
if (pop) precips.push(parseFloat(pop));
|
||||
}
|
||||
|
||||
if (precips.length > 0) {
|
||||
weather.precipitationProbability = Math.max(...precips);
|
||||
}
|
||||
}
|
||||
|
||||
#generateHourly (xml) {
|
||||
const hours = [];
|
||||
const hourlyMatches = xml.matchAll(/<hourlyForecast[^>]*dateTimeUTC="([^"]*)"[^>]*>(.*?)<\/hourlyForecast>/gs);
|
||||
|
||||
for (const [, dateTimeUTC, hourXML] of hourlyMatches) {
|
||||
const weather = {};
|
||||
|
||||
weather.date = this.#parseECTime(dateTimeUTC);
|
||||
|
||||
const temp = this.#extract(hourXML, /<temperature[^>]*>(.*?)<\/temperature>/);
|
||||
if (temp) weather.temperature = parseFloat(temp);
|
||||
|
||||
const lop = this.#extract(hourXML, /<lop[^>]*>(.*?)<\/lop>/);
|
||||
if (lop) weather.precipitationProbability = parseFloat(lop);
|
||||
|
||||
const icon = this.#extract(hourXML, /<iconCode[^>]*>(.*?)<\/iconCode>/);
|
||||
if (icon) weather.weatherType = this.#convertWeatherType(icon);
|
||||
|
||||
hours.push(weather);
|
||||
if (hours.length >= 24) break;
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#extract (text, pattern) {
|
||||
const match = text.match(pattern);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
#getIndexUrl () {
|
||||
const hour = new Date().toISOString().substring(11, 13);
|
||||
return `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}/${hour}/`;
|
||||
}
|
||||
|
||||
#extractCityPageURL (html) {
|
||||
// New format: {timestamp}_MSC_CitypageWeather_{siteCode}_en.xml
|
||||
const pattern = `[^"]*_MSC_CitypageWeather_${this.config.siteCode}_en\\.xml`;
|
||||
const match = html.match(new RegExp(`href="(${pattern})"`));
|
||||
|
||||
if (match && match[1]) {
|
||||
return this.#getIndexUrl() + match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#parseECTime (timeStr) {
|
||||
if (!timeStr || timeStr.length < 12) return new Date();
|
||||
|
||||
const y = parseInt(timeStr.substring(0, 4), 10);
|
||||
const m = parseInt(timeStr.substring(4, 6), 10) - 1;
|
||||
const d = parseInt(timeStr.substring(6, 8), 10);
|
||||
const h = parseInt(timeStr.substring(8, 10), 10);
|
||||
const min = parseInt(timeStr.substring(10, 12), 10);
|
||||
const s = timeStr.length >= 14 ? parseInt(timeStr.substring(12, 14), 10) : 0;
|
||||
|
||||
// Create UTC date since input timestamps are in UTC
|
||||
return new Date(Date.UTC(y, m, d, h, min, s));
|
||||
}
|
||||
|
||||
#convertWeatherType (iconCode) {
|
||||
const code = parseInt(iconCode, 10);
|
||||
const map = {
|
||||
0: "day-sunny",
|
||||
1: "day-sunny",
|
||||
2: "day-sunny-overcast",
|
||||
3: "day-cloudy",
|
||||
4: "day-cloudy",
|
||||
5: "day-cloudy",
|
||||
6: "day-sprinkle",
|
||||
7: "day-showers",
|
||||
8: "snow",
|
||||
9: "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 map[code] || null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EnvCanadaProvider;
|
||||
@@ -0,0 +1,525 @@
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Open-Meteo
|
||||
* see https://open-meteo.com/
|
||||
*/
|
||||
class OpenMeteoProvider {
|
||||
// https://open-meteo.com/en/docs
|
||||
hourlyParams = [
|
||||
"temperature_2m",
|
||||
"relativehumidity_2m",
|
||||
"dewpoint_2m",
|
||||
"apparent_temperature",
|
||||
"pressure_msl",
|
||||
"surface_pressure",
|
||||
"cloudcover",
|
||||
"cloudcover_low",
|
||||
"cloudcover_mid",
|
||||
"cloudcover_high",
|
||||
"windspeed_10m",
|
||||
"windspeed_80m",
|
||||
"windspeed_120m",
|
||||
"windspeed_180m",
|
||||
"winddirection_10m",
|
||||
"winddirection_80m",
|
||||
"winddirection_120m",
|
||||
"winddirection_180m",
|
||||
"windgusts_10m",
|
||||
"shortwave_radiation",
|
||||
"direct_radiation",
|
||||
"direct_normal_irradiance",
|
||||
"diffuse_radiation",
|
||||
"vapor_pressure_deficit",
|
||||
"cape",
|
||||
"evapotranspiration",
|
||||
"et0_fao_evapotranspiration",
|
||||
"precipitation",
|
||||
"snowfall",
|
||||
"precipitation_probability",
|
||||
"rain",
|
||||
"showers",
|
||||
"weathercode",
|
||||
"snow_depth",
|
||||
"freezinglevel_height",
|
||||
"visibility",
|
||||
"soil_temperature_0cm",
|
||||
"soil_temperature_6cm",
|
||||
"soil_temperature_18cm",
|
||||
"soil_temperature_54cm",
|
||||
"soil_moisture_0_1cm",
|
||||
"soil_moisture_1_3cm",
|
||||
"soil_moisture_3_9cm",
|
||||
"soil_moisture_9_27cm",
|
||||
"soil_moisture_27_81cm",
|
||||
"uv_index",
|
||||
"uv_index_clear_sky",
|
||||
"is_day",
|
||||
"terrestrial_radiation",
|
||||
"terrestrial_radiation_instant",
|
||||
"shortwave_radiation_instant",
|
||||
"diffuse_radiation_instant",
|
||||
"direct_radiation_instant",
|
||||
"direct_normal_irradiance_instant"
|
||||
];
|
||||
|
||||
dailyParams = [
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"apparent_temperature_min",
|
||||
"apparent_temperature_max",
|
||||
"precipitation_sum",
|
||||
"rain_sum",
|
||||
"showers_sum",
|
||||
"snowfall_sum",
|
||||
"precipitation_hours",
|
||||
"weathercode",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"windspeed_10m_max",
|
||||
"windgusts_10m_max",
|
||||
"winddirection_10m_dominant",
|
||||
"shortwave_radiation_sum",
|
||||
"uv_index_max",
|
||||
"et0_fao_evapotranspiration"
|
||||
];
|
||||
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: OPEN_METEO_BASE,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
pastDays: 0,
|
||||
type: "current",
|
||||
maxNumberOfDays: 5,
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.locationName = null;
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
async initialize () {
|
||||
await this.#fetchLocation();
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callbacks for data/error events
|
||||
* @param {(data: object) => void} onData - Called with weather data
|
||||
* @param {(error: object) => void} onError - Called with error info
|
||||
*/
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic fetching
|
||||
*/
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic fetching
|
||||
*/
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchLocation () {
|
||||
const url = `${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang || "en"}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data && data.city) {
|
||||
this.locationName = `${data.city}, ${data.principalSubdivisionCode}`;
|
||||
}
|
||||
} catch (error) {
|
||||
Log.debug("[openmeteo] Could not load location data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
logContext: "weatherprovider.openmeteo"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[openmeteo] Failed to parse JSON:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
const parsedData = this.#parseWeatherApiResponse(data);
|
||||
|
||||
if (!parsedData) {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Invalid API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let weatherData;
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateWeatherDayFromCurrentWeather(parsedData);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateWeatherObjectsFromForecast(parsedData);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateWeatherObjectsFromHourly(parsedData);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[openmeteo] Unknown type: ${this.config.type}`);
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[openmeteo] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getQueryParameters () {
|
||||
let maxNumberOfDays = this.config.maxNumberOfDays;
|
||||
|
||||
if (this.config.maxNumberOfDays !== undefined && !isNaN(parseFloat(this.config.maxNumberOfDays))) {
|
||||
const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0;
|
||||
const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0;
|
||||
const maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit));
|
||||
maxNumberOfDays = Math.ceil(maxEntries / Math.max(1, daysFactor));
|
||||
}
|
||||
|
||||
const 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,
|
||||
temperature_unit: "celsius",
|
||||
windspeed_unit: "ms",
|
||||
precipitation_unit: "mm"
|
||||
};
|
||||
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
case "daily":
|
||||
case "forecast":
|
||||
params.forecast_days = maxNumberOfDays + 1; // Open-Meteo counts today as day 1, so maxNumberOfDays=5 needs forecast_days=6
|
||||
break;
|
||||
case "current":
|
||||
params.current_weather = true;
|
||||
params.forecast_days = 1;
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
return Object.keys(params)
|
||||
.filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== "")
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
case "hourly":
|
||||
case "daily":
|
||||
return `${encodeURIComponent(key)}=${params[key].join(",")}`;
|
||||
default:
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`;
|
||||
}
|
||||
})
|
||||
.join("&");
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
return `${this.config.apiBase}/forecast?${this.#getQueryParameters()}`;
|
||||
}
|
||||
|
||||
#transposeDataMatrix (data) {
|
||||
return data.time.map((_, index) => Object.keys(data).reduce((row, key) => {
|
||||
const value = data[key][index];
|
||||
return {
|
||||
...row,
|
||||
// Convert Unix timestamps to Date objects
|
||||
// timezone: "auto" returns times already in location timezone
|
||||
[key]: ["time", "sunrise", "sunset"].includes(key) ? new Date(value * 1000) : value
|
||||
};
|
||||
}, {}));
|
||||
}
|
||||
|
||||
#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
|
||||
};
|
||||
|
||||
const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type;
|
||||
|
||||
if (!validByType[type]) return null;
|
||||
|
||||
if (type === "current" && !validByType.daily && !validByType.hourly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 = new Date(data.current_weather.time * 1000);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
#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-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 (!(weathercode in weatherConditions)) return null;
|
||||
|
||||
const mappings = {
|
||||
clear: isDayTime ? "day-sunny" : "night-clear",
|
||||
"mainly-clear": isDayTime ? "day-cloudy" : "night-alt-cloudy",
|
||||
"partly-cloudy": isDayTime ? "day-cloudy" : "night-alt-cloudy",
|
||||
overcast: isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy",
|
||||
fog: isDayTime ? "day-fog" : "night-fog",
|
||||
"depositing-rime-fog": isDayTime ? "day-fog" : "night-fog",
|
||||
"drizzle-light-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
|
||||
"rain-slight-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
|
||||
"rain-showers-slight": isDayTime ? "day-sprinkle" : "night-sprinkle",
|
||||
"drizzle-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
|
||||
"rain-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
|
||||
"rain-showers-moderate": isDayTime ? "day-showers" : "night-showers",
|
||||
"drizzle-dense-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"rain-heavy-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"rain-showers-violent": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"freezing-rain-light-intensity": isDayTime ? "day-rain-mix" : "night-rain-mix",
|
||||
"freezing-drizzle-light-intensity": "snowflake-cold",
|
||||
"freezing-drizzle-dense-intensity": "snowflake-cold",
|
||||
"snow-grains": isDayTime ? "day-sleet" : "night-sleet",
|
||||
"snow-fall-slight-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
|
||||
"snow-fall-moderate-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
|
||||
"snow-fall-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
||||
"freezing-rain-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
||||
"snow-showers-slight": isDayTime ? "day-rain-mix" : "night-rain-mix",
|
||||
"snow-showers-heavy": isDayTime ? "day-rain-mix" : "night-rain-mix",
|
||||
thunderstorm: isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
"thunderstorm-slight-hail": isDayTime ? "day-sleet" : "night-sleet",
|
||||
"thunderstorm-heavy-hail": isDayTime ? "day-sleet-storm" : "night-sleet-storm"
|
||||
};
|
||||
|
||||
return mappings[weatherConditions[`${weathercode}`]] || "na";
|
||||
}
|
||||
|
||||
#isDayTime (date, sunrise, sunset) {
|
||||
const time = date.getTime();
|
||||
return time >= sunrise.getTime() && time < sunset.getTime();
|
||||
}
|
||||
|
||||
#generateWeatherDayFromCurrentWeather (parsedData) {
|
||||
// Basic current weather data
|
||||
const current = {
|
||||
date: parsedData.current_weather.time,
|
||||
windSpeed: parsedData.current_weather.windspeed,
|
||||
windFromDirection: parsedData.current_weather.winddirection,
|
||||
temperature: parsedData.current_weather.temperature,
|
||||
weatherType: this.#convertWeatherType(parsedData.current_weather.weathercode, true)
|
||||
};
|
||||
|
||||
// Add hourly data if available
|
||||
if (parsedData.hourly) {
|
||||
// Open-Meteo updates current_weather every 15 min, but hourly entries only
|
||||
// exist at full hours — find the last entry at or before the current time.
|
||||
const currentMs = parsedData.current_weather.time.getTime();
|
||||
const hourlyIndex = parsedData.hourly.findLastIndex((hour) => hour.time.getTime() <= currentMs);
|
||||
const hourData = parsedData.hourly[Math.max(0, hourlyIndex)];
|
||||
current.humidity = hourData.relativehumidity_2m;
|
||||
current.feelsLikeTemp = hourData.apparent_temperature;
|
||||
current.rain = hourData.rain;
|
||||
current.snow = hourData.snowfall != null ? hourData.snowfall * 10 : null;
|
||||
current.precipitationAmount = hourData.precipitation;
|
||||
current.precipitationProbability = hourData.precipitation_probability;
|
||||
current.uvIndex = hourData.uv_index;
|
||||
}
|
||||
|
||||
// Add daily data if available (after transpose, daily is array of objects)
|
||||
if (parsedData.daily && Array.isArray(parsedData.daily) && parsedData.daily[0]) {
|
||||
const today = parsedData.daily[0];
|
||||
if (today.sunrise) {
|
||||
current.sunrise = today.sunrise;
|
||||
}
|
||||
if (today.sunset) {
|
||||
current.sunset = today.sunset;
|
||||
// Update weatherType with correct day/night status
|
||||
if (current.sunrise && current.sunset) {
|
||||
current.weatherType = this.#convertWeatherType(
|
||||
parsedData.current_weather.weathercode,
|
||||
this.#isDayTime(parsedData.current_weather.time, current.sunrise, current.sunset)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (today.temperature_2m_min !== undefined) {
|
||||
current.minTemperature = today.temperature_2m_min;
|
||||
}
|
||||
if (today.temperature_2m_max !== undefined) {
|
||||
current.maxTemperature = today.temperature_2m_max;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromForecast (parsedData) {
|
||||
return parsedData.daily.map((weather) => ({
|
||||
date: weather.time,
|
||||
windSpeed: weather.windspeed_10m_max,
|
||||
windFromDirection: weather.winddirection_10m_dominant,
|
||||
sunrise: weather.sunrise,
|
||||
sunset: weather.sunset,
|
||||
temperature: parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2),
|
||||
minTemperature: parseFloat(weather.temperature_2m_min),
|
||||
maxTemperature: parseFloat(weather.temperature_2m_max),
|
||||
weatherType: this.#convertWeatherType(weather.weathercode, true),
|
||||
rain: weather.rain_sum != null ? parseFloat(weather.rain_sum) : null,
|
||||
snow: weather.snowfall_sum != null ? parseFloat(weather.snowfall_sum * 10) : null,
|
||||
precipitationAmount: weather.precipitation_sum != null ? parseFloat(weather.precipitation_sum) : null,
|
||||
precipitationProbability: weather.precipitation_hours != null ? parseFloat(weather.precipitation_hours * 100 / 24) : null,
|
||||
uvIndex: weather.uv_index_max != null ? parseFloat(weather.uv_index_max) : null
|
||||
}));
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromHourly (parsedData) {
|
||||
const hours = [];
|
||||
const now = new Date();
|
||||
|
||||
parsedData.hourly.forEach((weather, i) => {
|
||||
// Skip past entries
|
||||
if (weather.time <= now) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate daily index with bounds check
|
||||
const h = Math.ceil((i + 1) / 24) - 1;
|
||||
const safeH = Math.max(0, Math.min(h, parsedData.daily.length - 1));
|
||||
const dailyData = parsedData.daily[safeH];
|
||||
|
||||
const hourlyWeather = {
|
||||
date: weather.time,
|
||||
windSpeed: weather.windspeed_10m,
|
||||
windFromDirection: weather.winddirection_10m,
|
||||
sunrise: dailyData.sunrise,
|
||||
sunset: dailyData.sunset,
|
||||
temperature: parseFloat(weather.temperature_2m),
|
||||
minTemperature: parseFloat(dailyData.temperature_2m_min),
|
||||
maxTemperature: parseFloat(dailyData.temperature_2m_max),
|
||||
weatherType: this.#convertWeatherType(
|
||||
weather.weathercode,
|
||||
this.#isDayTime(weather.time, dailyData.sunrise, dailyData.sunset)
|
||||
),
|
||||
humidity: weather.relativehumidity_2m != null ? parseFloat(weather.relativehumidity_2m) : null,
|
||||
rain: weather.rain != null ? parseFloat(weather.rain) : null,
|
||||
snow: weather.snowfall != null ? parseFloat(weather.snowfall * 10) : null,
|
||||
precipitationAmount: weather.precipitation != null ? parseFloat(weather.precipitation) : null,
|
||||
precipitationProbability: weather.precipitation_probability != null ? parseFloat(weather.precipitation_probability) : null,
|
||||
uvIndex: weather.uv_index != null ? parseFloat(weather.uv_index) : null
|
||||
};
|
||||
|
||||
hours.push(hourlyWeather);
|
||||
});
|
||||
|
||||
return hours;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenMeteoProvider;
|
||||
@@ -0,0 +1,407 @@
|
||||
const Log = require("logger");
|
||||
const weatherUtils = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for OpenWeatherMap
|
||||
* see https://openweathermap.org/
|
||||
*/
|
||||
class OpenWeatherMapProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiVersion: "3.0",
|
||||
apiBase: "https://api.openweathermap.org/data/",
|
||||
weatherEndpoint: "/onecall",
|
||||
locationID: false,
|
||||
location: false,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
apiKey: "",
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
this.locationName = null;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
// Validate callbacks exist
|
||||
if (typeof this.onErrorCallback !== "function") {
|
||||
throw new Error("setCallbacks() must be called before initialize()");
|
||||
}
|
||||
|
||||
if (!this.config.apiKey) {
|
||||
Log.error("[openweathermap] API key is required");
|
||||
this.onErrorCallback({
|
||||
message: "API key is required",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
logContext: "weatherprovider.openweathermap"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[openweathermap] Failed to parse JSON:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
try {
|
||||
let weatherData;
|
||||
|
||||
if (this.config.weatherEndpoint === "/onecall") {
|
||||
// One Call API (v3.0)
|
||||
if (data.timezone) {
|
||||
this.locationName = data.timezone;
|
||||
}
|
||||
|
||||
const onecallData = this.#generateWeatherObjectsFromOnecall(data);
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = onecallData.current;
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = onecallData.days;
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = onecallData.hours;
|
||||
break;
|
||||
default:
|
||||
Log.error(`[openweathermap] Unknown type: ${this.config.type}`);
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
} else if (this.config.weatherEndpoint === "/weather") {
|
||||
// Current weather endpoint (API v2.5)
|
||||
weatherData = this.#generateWeatherObjectFromCurrentWeather(data);
|
||||
} else if (this.config.weatherEndpoint === "/forecast") {
|
||||
// 3-hourly forecast endpoint (API v2.5)
|
||||
weatherData = this.config.type === "hourly"
|
||||
? this.#generateHourlyWeatherObjectsFromForecast(data)
|
||||
: this.#generateDailyWeatherObjectsFromForecast(data);
|
||||
} else {
|
||||
throw new Error(`Unknown weather endpoint: ${this.config.weatherEndpoint}`);
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[openweathermap] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#generateWeatherObjectFromCurrentWeather (data) {
|
||||
const timezoneOffsetMinutes = (data.timezone ?? 0) / 60;
|
||||
|
||||
if (data.name && data.sys?.country) {
|
||||
this.locationName = `${data.name}, ${data.sys.country}`;
|
||||
} else if (data.name) {
|
||||
this.locationName = data.name;
|
||||
}
|
||||
|
||||
const weather = {};
|
||||
weather.date = weatherUtils.applyTimezoneOffset(new Date(data.dt * 1000), timezoneOffsetMinutes);
|
||||
weather.temperature = data.main.temp;
|
||||
weather.feelsLikeTemp = data.main.feels_like;
|
||||
weather.humidity = data.main.humidity;
|
||||
weather.windSpeed = data.wind.speed;
|
||||
weather.windFromDirection = data.wind.deg;
|
||||
weather.weatherType = weatherUtils.convertWeatherType(data.weather[0].icon);
|
||||
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunrise * 1000), timezoneOffsetMinutes);
|
||||
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunset * 1000), timezoneOffsetMinutes);
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
#extractThreeHourPrecipitation (forecast) {
|
||||
const rain = Number.parseFloat(forecast.rain?.["3h"] ?? "") || 0;
|
||||
const snow = Number.parseFloat(forecast.snow?.["3h"] ?? "") || 0;
|
||||
const precipitationAmount = rain + snow;
|
||||
|
||||
return {
|
||||
rain,
|
||||
snow,
|
||||
precipitationAmount,
|
||||
hasPrecipitation: precipitationAmount > 0
|
||||
};
|
||||
}
|
||||
|
||||
#generateHourlyWeatherObjectsFromForecast (data) {
|
||||
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
|
||||
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
|
||||
|
||||
if (data.city?.name && data.city?.country) {
|
||||
this.locationName = `${data.city.name}, ${data.city.country}`;
|
||||
}
|
||||
|
||||
return data.list.map((forecast) => {
|
||||
const weather = {};
|
||||
weather.date = weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes);
|
||||
weather.temperature = forecast.main.temp;
|
||||
weather.feelsLikeTemp = forecast.main.feels_like;
|
||||
weather.humidity = forecast.main.humidity;
|
||||
weather.windSpeed = forecast.wind.speed;
|
||||
weather.windFromDirection = forecast.wind.deg;
|
||||
weather.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
|
||||
weather.precipitationProbability = forecast.pop !== undefined ? forecast.pop * 100 : undefined;
|
||||
|
||||
const precipitation = this.#extractThreeHourPrecipitation(forecast);
|
||||
if (precipitation.hasPrecipitation) {
|
||||
weather.rain = precipitation.rain;
|
||||
weather.snow = precipitation.snow;
|
||||
weather.precipitationAmount = precipitation.precipitationAmount;
|
||||
}
|
||||
|
||||
return weather;
|
||||
});
|
||||
}
|
||||
|
||||
#generateDailyWeatherObjectsFromForecast (data) {
|
||||
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
|
||||
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
|
||||
|
||||
if (data.city?.name && data.city?.country) {
|
||||
this.locationName = `${data.city.name}, ${data.city.country}`;
|
||||
}
|
||||
|
||||
const dayMap = new Map();
|
||||
|
||||
for (const forecast of data.list) {
|
||||
// Shift dt by timezone offset so UTC fields represent local time
|
||||
const localDate = new Date((forecast.dt + timezoneOffsetSeconds) * 1000);
|
||||
const dateKey = `${localDate.getUTCFullYear()}-${String(localDate.getUTCMonth() + 1).padStart(2, "0")}-${String(localDate.getUTCDate()).padStart(2, "0")}`;
|
||||
|
||||
if (!dayMap.has(dateKey)) {
|
||||
dayMap.set(dateKey, {
|
||||
date: weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes),
|
||||
minTemps: [],
|
||||
maxTemps: [],
|
||||
rain: 0,
|
||||
snow: 0,
|
||||
weatherType: weatherUtils.convertWeatherType(forecast.weather[0].icon)
|
||||
});
|
||||
}
|
||||
|
||||
const day = dayMap.get(dateKey);
|
||||
day.minTemps.push(forecast.main.temp_min);
|
||||
day.maxTemps.push(forecast.main.temp_max);
|
||||
|
||||
const hour = localDate.getUTCHours();
|
||||
if (hour >= 8 && hour <= 17) {
|
||||
day.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
|
||||
}
|
||||
|
||||
const precipitation = this.#extractThreeHourPrecipitation(forecast);
|
||||
day.rain += precipitation.rain;
|
||||
day.snow += precipitation.snow;
|
||||
}
|
||||
|
||||
return Array.from(dayMap.values()).map((day) => ({
|
||||
date: day.date,
|
||||
minTemperature: Math.min(...day.minTemps),
|
||||
maxTemperature: Math.max(...day.maxTemps),
|
||||
weatherType: day.weatherType,
|
||||
rain: day.rain,
|
||||
snow: day.snow,
|
||||
precipitationAmount: day.rain + day.snow
|
||||
}));
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromOnecall (data) {
|
||||
let precip;
|
||||
|
||||
// Get current weather
|
||||
const current = {};
|
||||
if (data.hasOwnProperty("current")) {
|
||||
const timezoneOffset = data.timezone_offset / 60;
|
||||
current.date = weatherUtils.applyTimezoneOffset(new Date(data.current.dt * 1000), timezoneOffset);
|
||||
current.windSpeed = data.current.wind_speed;
|
||||
current.windFromDirection = data.current.wind_deg;
|
||||
current.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.current.sunrise * 1000), timezoneOffset);
|
||||
current.sunset = weatherUtils.applyTimezoneOffset(new Date(data.current.sunset * 1000), timezoneOffset);
|
||||
current.temperature = data.current.temp;
|
||||
current.weatherType = weatherUtils.convertWeatherType(data.current.weather[0].icon);
|
||||
current.humidity = data.current.humidity;
|
||||
current.uvIndex = data.current.uvi;
|
||||
|
||||
precip = false;
|
||||
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;
|
||||
}
|
||||
|
||||
// Get hourly weather
|
||||
const hours = [];
|
||||
if (data.hasOwnProperty("hourly")) {
|
||||
const timezoneOffset = data.timezone_offset / 60;
|
||||
for (const hour of data.hourly) {
|
||||
const weather = {};
|
||||
weather.date = weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset);
|
||||
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 = weatherUtils.convertWeatherType(hour.weather[0].icon);
|
||||
weather.precipitationProbability = hour.pop !== undefined ? hour.pop * 100 : undefined;
|
||||
weather.uvIndex = hour.uvi;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Get daily weather
|
||||
const days = [];
|
||||
if (data.hasOwnProperty("daily")) {
|
||||
const timezoneOffset = data.timezone_offset / 60;
|
||||
for (const day of data.daily) {
|
||||
const weather = {};
|
||||
weather.date = weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset);
|
||||
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(day.sunrise * 1000), timezoneOffset);
|
||||
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(day.sunset * 1000), timezoneOffset);
|
||||
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 = weatherUtils.convertWeatherType(day.weather[0].icon);
|
||||
weather.precipitationProbability = day.pop !== undefined ? day.pop * 100 : undefined;
|
||||
weather.uvIndex = day.uvi;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return { current, hours, days };
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.#getParams();
|
||||
}
|
||||
|
||||
#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}`;
|
||||
}
|
||||
|
||||
params += "&units=metric";
|
||||
params += `&lang=${this.config.lang || "en"}`;
|
||||
params += `&APPID=${this.config.apiKey}`;
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenWeatherMapProvider;
|
||||
@@ -0,0 +1,271 @@
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
class PirateweatherProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://api.pirateweather.net",
|
||||
weatherEndpoint: "/forecast",
|
||||
apiKey: "",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
lang: "en",
|
||||
...config
|
||||
};
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
if (!this.config.apiKey) {
|
||||
Log.error("[pirateweather] No API key configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "API key required",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
Accept: "application/json"
|
||||
},
|
||||
logContext: "weatherprovider.pirateweather"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[pirateweather] Parse error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
if (!data || (!data.currently && !data.daily && !data.hourly)) {
|
||||
Log.error("[pirateweather] No usable data received");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "No usable data in API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(data);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[pirateweather] Unknown weather type: ${this.config.type}`);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: `Unknown weather type: ${this.config.type}`,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
if (!data.currently || typeof data.currently.temperature === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = {
|
||||
date: new Date(),
|
||||
humidity: data.currently.humidity != null ? parseFloat(data.currently.humidity) * 100 : null,
|
||||
temperature: parseFloat(data.currently.temperature),
|
||||
feelsLikeTemp: data.currently.apparentTemperature != null ? parseFloat(data.currently.apparentTemperature) : null,
|
||||
windSpeed: data.currently.windSpeed != null ? parseFloat(data.currently.windSpeed) : null,
|
||||
windFromDirection: data.currently.windBearing || null,
|
||||
weatherType: this.#convertWeatherType(data.currently.icon),
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
// Add sunrise/sunset from daily data if available
|
||||
if (data.daily && data.daily.data && data.daily.data.length > 0) {
|
||||
const today = data.daily.data[0];
|
||||
if (today.sunriseTime) {
|
||||
current.sunrise = new Date(today.sunriseTime * 1000);
|
||||
}
|
||||
if (today.sunsetTime) {
|
||||
current.sunset = new Date(today.sunsetTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
if (!data.daily || !data.daily.data || !data.daily.data.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const days = [];
|
||||
|
||||
for (const forecast of data.daily.data) {
|
||||
const day = {
|
||||
date: new Date(forecast.time * 1000),
|
||||
minTemperature: forecast.temperatureMin != null ? parseFloat(forecast.temperatureMin) : null,
|
||||
maxTemperature: forecast.temperatureMax != null ? parseFloat(forecast.temperatureMax) : null,
|
||||
weatherType: this.#convertWeatherType(forecast.icon),
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0,
|
||||
precipitationProbability: forecast.precipProbability != null ? parseFloat(forecast.precipProbability) * 100 : null
|
||||
};
|
||||
|
||||
// Handle precipitation
|
||||
let precip = 0;
|
||||
if (forecast.hasOwnProperty("precipAccumulation")) {
|
||||
precip = forecast.precipAccumulation * 10; // cm to mm
|
||||
}
|
||||
|
||||
day.precipitationAmount = precip;
|
||||
|
||||
if (forecast.precipType) {
|
||||
if (forecast.precipType === "snow") {
|
||||
day.snow = precip;
|
||||
} else {
|
||||
day.rain = precip;
|
||||
}
|
||||
}
|
||||
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
if (!data.hourly || !data.hourly.data || !data.hourly.data.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hours = [];
|
||||
|
||||
for (const forecast of data.hourly.data) {
|
||||
const hour = {
|
||||
date: new Date(forecast.time * 1000),
|
||||
temperature: forecast.temperature !== undefined ? parseFloat(forecast.temperature) : null,
|
||||
feelsLikeTemp: forecast.apparentTemperature !== undefined ? parseFloat(forecast.apparentTemperature) : null,
|
||||
weatherType: this.#convertWeatherType(forecast.icon),
|
||||
windSpeed: forecast.windSpeed !== undefined ? parseFloat(forecast.windSpeed) : null,
|
||||
windFromDirection: forecast.windBearing || null,
|
||||
precipitationProbability: forecast.precipProbability ? parseFloat(forecast.precipProbability) * 100 : null,
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0
|
||||
};
|
||||
|
||||
// Handle precipitation
|
||||
let precip = 0;
|
||||
if (forecast.hasOwnProperty("precipAccumulation")) {
|
||||
precip = forecast.precipAccumulation * 10; // cm to mm
|
||||
}
|
||||
|
||||
hour.precipitationAmount = precip;
|
||||
|
||||
if (forecast.precipType) {
|
||||
if (forecast.precipType === "snow") {
|
||||
hour.snow = precip;
|
||||
} else {
|
||||
hour.rain = precip;
|
||||
}
|
||||
}
|
||||
|
||||
hours.push(hour);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
const apiBase = this.config.apiBase || "https://api.pirateweather.net";
|
||||
const weatherEndpoint = this.config.weatherEndpoint || "/forecast";
|
||||
const lang = this.config.lang || "en";
|
||||
return `${apiBase}${weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${lang}`;
|
||||
}
|
||||
|
||||
#convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
"clear-day": "day-sunny",
|
||||
"clear-night": "night-clear",
|
||||
rain: "rain",
|
||||
snow: "snow",
|
||||
sleet: "snow",
|
||||
wind: "windy",
|
||||
fog: "fog",
|
||||
cloudy: "cloudy",
|
||||
"partly-cloudy-day": "day-cloudy",
|
||||
"partly-cloudy-night": "night-cloudy"
|
||||
};
|
||||
|
||||
return weatherTypes[weatherType] || null;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PirateweatherProvider;
|
||||
@@ -0,0 +1,504 @@
|
||||
const Log = require("logger");
|
||||
const { getSunTimes, isDayTime, validateCoordinates } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for SMHI (Swedish Meteorological and Hydrological Institute)
|
||||
* Sweden only, metric system
|
||||
*
|
||||
* API: SNOW1gv1 — https://opendata.smhi.se/metfcst/snow1gv1
|
||||
* Migrated from PMP3gv2 (deprecated 2026-03-31, returns HTTP 404)
|
||||
*
|
||||
* Version: 2.0.1 (2026-04-02)
|
||||
*
|
||||
* Key differences from PMP3gv2:
|
||||
* - URL: snow1g/version/1 (was pmp3g/version/2)
|
||||
* - Time key: "time" (was "validTime")
|
||||
* - Data structure: flat object entry.data.X (was parameters[].find().values[0])
|
||||
* - Parameter names: human-readable (air_temperature, wind_speed, etc.)
|
||||
* - Coordinates: flat [lon, lat] (was nested [[lon, lat]])
|
||||
* - Precipitation types: different value mapping (1=rain, not snow)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps user-facing config precipitationValue to SNOW1gv1 parameter names.
|
||||
* Maintains backward compatibility with existing MagicMirror configs.
|
||||
*/
|
||||
const PRECIP_VALUE_MAP = {
|
||||
pmin: "precipitation_amount_min",
|
||||
pmean: "precipitation_amount_mean",
|
||||
pmedian: "precipitation_amount_median",
|
||||
pmax: "precipitation_amount_max"
|
||||
};
|
||||
|
||||
class SMHIProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
precipitationValue: "pmedian", // pmin, pmean, pmedian, pmax
|
||||
type: "current",
|
||||
updateInterval: 5 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
// Validate precipitationValue
|
||||
if (!Object.keys(PRECIP_VALUE_MAP).includes(this.config.precipitationValue)) {
|
||||
Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`);
|
||||
this.config.precipitationValue = "pmedian";
|
||||
}
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
try {
|
||||
// SMHI requires max 6 decimal places
|
||||
validateCoordinates(this.config, 6);
|
||||
this.#initializeFetcher();
|
||||
} catch (error) {
|
||||
Log.error("[smhi] Initialization failed:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
logContext: "weatherprovider.smhi"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[smhi] Failed to parse JSON:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
try {
|
||||
if (!data.timeSeries || !Array.isArray(data.timeSeries)) {
|
||||
throw new Error("Invalid weather data");
|
||||
}
|
||||
|
||||
const coordinates = this.#resolveCoordinates(data);
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrentWeather(data.timeSeries, coordinates);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateForecast(data.timeSeries, coordinates);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data.timeSeries, coordinates);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[smhi] Unknown weather type: ${this.config.type}`);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: `Unknown weather type: ${this.config.type}`,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[smhi] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrentWeather (timeSeries, coordinates) {
|
||||
const closest = this.#getClosestToCurrentTime(timeSeries);
|
||||
return this.#convertWeatherDataToObject(closest, coordinates);
|
||||
}
|
||||
|
||||
#generateForecast (timeSeries, coordinates) {
|
||||
const filled = this.#fillInGaps(timeSeries);
|
||||
return this.#convertWeatherDataGroupedBy(filled, coordinates, "day");
|
||||
}
|
||||
|
||||
#generateHourly (timeSeries, coordinates) {
|
||||
const filled = this.#fillInGaps(timeSeries);
|
||||
return this.#convertWeatherDataGroupedBy(filled, coordinates, "hour");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the time series entry closest to the current time.
|
||||
* SNOW1gv1 uses "time" instead of PMP3gv2's "validTime".
|
||||
* @param {Array<object>} times - Array of SNOW1gv1 time series entries.
|
||||
* @returns {object} The time series entry closest to the current time.
|
||||
*/
|
||||
#getClosestToCurrentTime (times) {
|
||||
const now = new Date();
|
||||
let minDiff = null;
|
||||
let closest = times[0];
|
||||
|
||||
for (const time of times) {
|
||||
const entryTime = new Date(time.time);
|
||||
const diff = Math.abs(entryTime - now);
|
||||
|
||||
if (minDiff === null || diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = time;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single SNOW1gv1 time series entry to MagicMirror weather object.
|
||||
*
|
||||
* SNOW1gv1 data structure: entry.data.parameter_name (flat object)
|
||||
* PMP3gv2 used: entry.parameters[{name, values}] (array of objects)
|
||||
* @param {object} weatherData - A single SNOW1gv1 time series entry.
|
||||
* @param {object} coordinates - Object with lat and lon properties.
|
||||
* @returns {object} MagicMirror-formatted weather data object.
|
||||
*/
|
||||
#convertWeatherDataToObject (weatherData, coordinates) {
|
||||
const date = new Date(weatherData.time);
|
||||
const { sunrise, sunset } = getSunTimes(date, coordinates.lat, coordinates.lon);
|
||||
const isDay = isDayTime(date, sunrise, sunset);
|
||||
|
||||
const current = {
|
||||
date: date,
|
||||
humidity: this.#paramValue(weatherData, "relative_humidity"),
|
||||
temperature: this.#paramValue(weatherData, "air_temperature"),
|
||||
windSpeed: this.#paramValue(weatherData, "wind_speed"),
|
||||
windFromDirection: this.#paramValue(weatherData, "wind_from_direction"),
|
||||
weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "symbol_code"), isDay),
|
||||
feelsLikeTemp: this.#calculateApparentTemperature(weatherData),
|
||||
sunrise: sunrise,
|
||||
sunset: sunset,
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0
|
||||
};
|
||||
|
||||
// Map user config (pmedian/pmean/pmin/pmax) to SNOW1gv1 parameter name
|
||||
const precipParamName = PRECIP_VALUE_MAP[this.config.precipitationValue];
|
||||
const precipitationValue = this.#paramValue(weatherData, precipParamName);
|
||||
const pcat = this.#paramValue(weatherData, "predominant_precipitation_type_at_surface");
|
||||
|
||||
// SNOW1gv1 precipitation type mapping (differs from PMP3gv2!):
|
||||
// 0 = no precipitation
|
||||
// 1 = rain
|
||||
// 2 = sleet (snow + rain mix)
|
||||
// 5 = snow / freezing rain
|
||||
// 6 = freezing mixed precipitation
|
||||
// 11 = drizzle / light rain
|
||||
switch (pcat) {
|
||||
case 1: // Rain
|
||||
case 11: // Drizzle / light rain
|
||||
current.rain = precipitationValue;
|
||||
current.precipitationAmount = precipitationValue;
|
||||
break;
|
||||
case 2: // Sleet / mixed rain and snow
|
||||
current.snow = precipitationValue / 2;
|
||||
current.rain = precipitationValue / 2;
|
||||
current.precipitationAmount = precipitationValue;
|
||||
break;
|
||||
case 5: // Snow / freezing rain
|
||||
case 6: // Freezing mixed precipitation
|
||||
current.snow = precipitationValue;
|
||||
current.precipitationAmount = precipitationValue;
|
||||
break;
|
||||
case 0:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") {
|
||||
const result = [];
|
||||
let currentWeather = null;
|
||||
let dayWeatherTypes = [];
|
||||
|
||||
const allWeatherObjects = allWeatherData.map((data) => this.#convertWeatherDataToObject(data, coordinates));
|
||||
|
||||
for (const weatherObject of allWeatherObjects) {
|
||||
const objDate = new Date(weatherObject.date);
|
||||
|
||||
// Check if we need a new group (day or hour change)
|
||||
const needNewGroup = !currentWeather || !this.#isSamePeriod(currentWeather.date, objDate, groupBy);
|
||||
|
||||
if (needNewGroup) {
|
||||
currentWeather = {
|
||||
date: objDate,
|
||||
temperature: weatherObject.temperature,
|
||||
minTemperature: Infinity,
|
||||
maxTemperature: -Infinity,
|
||||
snow: 0,
|
||||
rain: 0,
|
||||
precipitationAmount: 0,
|
||||
sunrise: weatherObject.sunrise,
|
||||
sunset: weatherObject.sunset
|
||||
};
|
||||
dayWeatherTypes = [];
|
||||
result.push(currentWeather);
|
||||
}
|
||||
|
||||
// Track weather types during daytime
|
||||
const { sunrise: daySunrise, sunset: daySunset } = getSunTimes(objDate, coordinates.lat, coordinates.lon);
|
||||
const isDay = isDayTime(objDate, daySunrise, daySunset);
|
||||
|
||||
if (isDay) {
|
||||
dayWeatherTypes.push(weatherObject.weatherType);
|
||||
}
|
||||
|
||||
// Use median weather type from daytime hours
|
||||
if (dayWeatherTypes.length > 0) {
|
||||
currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)];
|
||||
} else {
|
||||
currentWeather.weatherType = weatherObject.weatherType;
|
||||
}
|
||||
|
||||
// Aggregate min/max and precipitation
|
||||
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;
|
||||
}
|
||||
|
||||
#isSamePeriod (date1, date2, groupBy) {
|
||||
if (groupBy === "hour") {
|
||||
return date1.getFullYear() === date2.getFullYear()
|
||||
&& date1.getMonth() === date2.getMonth()
|
||||
&& date1.getDate() === date2.getDate()
|
||||
&& date1.getHours() === date2.getHours();
|
||||
} else { // day
|
||||
return date1.getFullYear() === date2.getFullYear()
|
||||
&& date1.getMonth() === date2.getMonth()
|
||||
&& date1.getDate() === date2.getDate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill gaps in time series data for forecast/hourly grouping.
|
||||
* SNOW1gv1 has variable time steps: 1h (0-48h), 2h (49-72h), 6h (73-132h), 12h (133h+).
|
||||
* Uses "time" key instead of PMP3gv2's "validTime".
|
||||
* @param {Array<object>} data - Array of SNOW1gv1 time series entries.
|
||||
* @returns {Array<object>} Time series with hourly gaps filled using previous entry data.
|
||||
*/
|
||||
#fillInGaps (data) {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const result = [];
|
||||
result.push(data[0]);
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const from = new Date(data[i - 1].time);
|
||||
const to = new Date(data[i].time);
|
||||
const hours = Math.floor((to - from) / (1000 * 60 * 60));
|
||||
|
||||
// Fill gaps with previous data point (start at j=1 since j=0 is already pushed)
|
||||
for (let j = 1; j < hours; j++) {
|
||||
const current = { ...data[i - 1] };
|
||||
const newTime = new Date(from);
|
||||
newTime.setHours(from.getHours() + j);
|
||||
current.time = newTime.toISOString();
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
// Push original data point
|
||||
result.push(data[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract coordinates from SNOW1gv1 response.
|
||||
* SNOW1gv1 returns flat GeoJSON Point: { coordinates: [lon, lat] }
|
||||
* PMP3gv2 returned nested: { coordinates: [[lon, lat]] }
|
||||
* @param {object} data - The full SNOW1gv1 API response object.
|
||||
* @returns {object} Object with lat and lon properties.
|
||||
*/
|
||||
#resolveCoordinates (data) {
|
||||
const coords = data?.geometry?.coordinates;
|
||||
|
||||
if (Array.isArray(coords) && coords.length >= 2 && typeof coords[0] === "number") {
|
||||
// SNOW1gv1 flat format: [lon, lat]
|
||||
return {
|
||||
lat: coords[1],
|
||||
lon: coords[0]
|
||||
};
|
||||
}
|
||||
|
||||
Log.warn("[smhi] Invalid coordinate structure in response, using config values");
|
||||
return {
|
||||
lat: this.config.lat,
|
||||
lon: this.config.lon
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate apparent (feels-like) temperature using humidity and wind.
|
||||
* Uses SNOW1gv1 parameter names.
|
||||
* @param {object} weatherData - A single SNOW1gv1 time series entry.
|
||||
* @returns {number|null} Apparent temperature in °C, or raw temperature if data is missing.
|
||||
*/
|
||||
#calculateApparentTemperature (weatherData) {
|
||||
const Ta = this.#paramValue(weatherData, "air_temperature");
|
||||
const rh = this.#paramValue(weatherData, "relative_humidity");
|
||||
const ws = this.#paramValue(weatherData, "wind_speed");
|
||||
|
||||
if (Ta === null || rh === null || ws === null) {
|
||||
return Ta; // Fallback to raw temperature if data missing
|
||||
}
|
||||
|
||||
const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
|
||||
return Ta + 0.33 * p - 0.7 * ws - 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameter value from SNOW1gv1 flat data structure.
|
||||
* SNOW1gv1: weatherData.data.parameter_name (direct property access)
|
||||
* PMP3gv2 used: weatherData.parameters.find(p => p.name === name).values[0]
|
||||
*
|
||||
* Returns null if parameter missing or equals SMHI missing value (9999).
|
||||
* @param {object} weatherData - A single SNOW1gv1 time series entry.
|
||||
* @param {string} name - The SNOW1gv1 parameter name to look up.
|
||||
* @returns {number|null} The parameter value, or null if missing.
|
||||
*/
|
||||
#paramValue (weatherData, name) {
|
||||
const value = weatherData.data?.[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// SMHI uses 9999 as missing value sentinel for all parameters
|
||||
if (value === 9999) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SMHI symbol_code (1-27) to MagicMirror weather icon names.
|
||||
* Symbol codes are identical between PMP3gv2 and SNOW1gv1.
|
||||
* @param {number} input - SMHI symbol_code value (1-27).
|
||||
* @param {boolean} isDayTime - Whether the current time is during daytime.
|
||||
* @returns {string|null} MagicMirror weather icon name, or null if unknown.
|
||||
*/
|
||||
#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:
|
||||
case 4:
|
||||
return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable/halfclear cloudiness
|
||||
case 5:
|
||||
case 6:
|
||||
return "cloudy"; // Cloudy/overcast
|
||||
case 7:
|
||||
return "fog";
|
||||
case 8:
|
||||
case 9:
|
||||
case 10:
|
||||
return "showers"; // Light/moderate/heavy rain showers
|
||||
case 11:
|
||||
case 21:
|
||||
return "thunderstorm";
|
||||
case 12:
|
||||
case 13:
|
||||
case 14:
|
||||
case 22:
|
||||
case 23:
|
||||
case 24:
|
||||
return "sleet"; // Light/moderate/heavy sleet (showers)
|
||||
case 15:
|
||||
case 16:
|
||||
case 17:
|
||||
case 25:
|
||||
case 26:
|
||||
case 27:
|
||||
return "snow"; // Light/moderate/heavy snow (showers/fall)
|
||||
case 18:
|
||||
case 19:
|
||||
case 20:
|
||||
return "rain"; // Light/moderate/heavy rain
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SNOW1gv1 forecast URL.
|
||||
* Changed from: pmp3g/version/2
|
||||
* Changed to: snow1g/version/1
|
||||
* @returns {string} The full SNOW1gv1 API URL for the configured coordinates.
|
||||
*/
|
||||
#getUrl () {
|
||||
const lon = this.config.lon.toFixed(6);
|
||||
const lat = this.config.lat.toFixed(6);
|
||||
return `https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/${lon}/lat/${lat}/data.json`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SMHIProvider;
|
||||
@@ -0,0 +1,330 @@
|
||||
const Log = require("logger");
|
||||
const { getSunTimes } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* UK Met Office Data Hub provider
|
||||
* For more information: https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub
|
||||
*
|
||||
* Data available:
|
||||
* - Hourly data for next 2 days (for current weather)
|
||||
* - 3-hourly data for next 7 days (for hourly forecasts)
|
||||
* - Daily data for next 7 days (for daily forecasts)
|
||||
*
|
||||
* Free accounts limited to 360 requests/day per service (once every 4 minutes)
|
||||
*/
|
||||
class UkMetOfficeDataHubProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/",
|
||||
apiKey: "",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") {
|
||||
Log.error("[ukmetofficedatahub] No API key configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "UK Met Office DataHub API key required. Get one at https://datahub.metoffice.gov.uk/",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const forecastType = this.#getForecastType();
|
||||
const url = this.#getUrl(forecastType);
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
apikey: this.config.apiKey
|
||||
},
|
||||
logContext: "weatherprovider.ukmetofficedatahub"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[ukmetofficedatahub] Parse error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#getForecastType () {
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
return "three-hourly";
|
||||
case "forecast":
|
||||
case "daily":
|
||||
return "daily";
|
||||
case "current":
|
||||
default:
|
||||
return "hourly";
|
||||
}
|
||||
}
|
||||
|
||||
#getUrl (forecastType) {
|
||||
const base = this.config.apiBase.endsWith("/") ? this.config.apiBase : `${this.config.apiBase}/`;
|
||||
const queryStrings = `?latitude=${this.config.lat}&longitude=${this.config.lon}&includeLocationName=true`;
|
||||
return `${base}${forecastType}${queryStrings}`;
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
if (!data || !data.features || !data.features[0] || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {
|
||||
Log.error("[ukmetofficedatahub] No usable data received");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "No usable data in API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(data);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[ukmetofficedatahub] Unknown weather type: ${this.config.type}`);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: `Unknown weather type: ${this.config.type}`,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
const timeSeries = data.features[0].properties.timeSeries;
|
||||
const now = new Date();
|
||||
|
||||
// Find the hour that contains current time
|
||||
for (const hour of timeSeries) {
|
||||
const forecastTime = new Date(hour.time);
|
||||
const oneHourLater = new Date(forecastTime.getTime() + 60 * 60 * 1000);
|
||||
|
||||
if (now >= forecastTime && now < oneHourLater) {
|
||||
const current = {
|
||||
date: forecastTime,
|
||||
temperature: hour.screenTemperature || null,
|
||||
minTemperature: hour.minScreenAirTemp || null,
|
||||
maxTemperature: hour.maxScreenAirTemp || null,
|
||||
windSpeed: hour.windSpeed10m || null,
|
||||
windFromDirection: hour.windDirectionFrom10m || null,
|
||||
weatherType: this.#convertWeatherType(hour.significantWeatherCode),
|
||||
humidity: hour.screenRelativeHumidity || null,
|
||||
rain: hour.totalPrecipAmount || 0,
|
||||
snow: hour.totalSnowAmount || 0,
|
||||
precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0),
|
||||
precipitationProbability: hour.probOfPrecipitation || null,
|
||||
feelsLikeTemp: hour.feelsLikeTemperature || null,
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
// Calculate sunrise/sunset using SunCalc
|
||||
const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon);
|
||||
current.sunrise = sunrise;
|
||||
current.sunset = sunset;
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first hour if no match found
|
||||
const firstHour = timeSeries[0];
|
||||
const current = {
|
||||
date: new Date(firstHour.time),
|
||||
temperature: firstHour.screenTemperature || null,
|
||||
windSpeed: firstHour.windSpeed10m || null,
|
||||
windFromDirection: firstHour.windDirectionFrom10m || null,
|
||||
weatherType: this.#convertWeatherType(firstHour.significantWeatherCode),
|
||||
humidity: firstHour.screenRelativeHumidity || null,
|
||||
rain: firstHour.totalPrecipAmount || 0,
|
||||
snow: firstHour.totalSnowAmount || 0,
|
||||
precipitationAmount: (firstHour.totalPrecipAmount || 0) + (firstHour.totalSnowAmount || 0),
|
||||
precipitationProbability: firstHour.probOfPrecipitation || null,
|
||||
feelsLikeTemp: firstHour.feelsLikeTemperature || null,
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon);
|
||||
current.sunrise = sunrise;
|
||||
current.sunset = sunset;
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
const timeSeries = data.features[0].properties.timeSeries;
|
||||
const days = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (const day of timeSeries) {
|
||||
const forecastDate = new Date(day.time);
|
||||
forecastDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Only include today and future days
|
||||
if (forecastDate >= today) {
|
||||
days.push({
|
||||
date: new Date(day.time),
|
||||
minTemperature: day.nightMinScreenTemperature || null,
|
||||
maxTemperature: day.dayMaxScreenTemperature || null,
|
||||
temperature: day.dayMaxScreenTemperature || null,
|
||||
windSpeed: day.midday10MWindSpeed || null,
|
||||
windFromDirection: day.midday10MWindDirection || null,
|
||||
weatherType: this.#convertWeatherType(day.daySignificantWeatherCode),
|
||||
humidity: day.middayRelativeHumidity || null,
|
||||
rain: day.dayProbabilityOfRain || 0,
|
||||
snow: day.dayProbabilityOfSnow || 0,
|
||||
precipitationAmount: 0,
|
||||
precipitationProbability: day.dayProbabilityOfPrecipitation || null,
|
||||
feelsLikeTemp: day.dayMaxFeelsLikeTemp || null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
const timeSeries = data.features[0].properties.timeSeries;
|
||||
const hours = [];
|
||||
|
||||
for (const hour of timeSeries) {
|
||||
// 3-hourly data uses maxScreenAirTemp/minScreenAirTemp, not screenTemperature
|
||||
const temp = hour.screenTemperature !== undefined
|
||||
? hour.screenTemperature
|
||||
: (hour.maxScreenAirTemp !== undefined && hour.minScreenAirTemp !== undefined)
|
||||
? (hour.maxScreenAirTemp + hour.minScreenAirTemp) / 2
|
||||
: null;
|
||||
|
||||
hours.push({
|
||||
date: new Date(hour.time),
|
||||
temperature: temp,
|
||||
windSpeed: hour.windSpeed10m || null,
|
||||
windFromDirection: hour.windDirectionFrom10m || null,
|
||||
weatherType: this.#convertWeatherType(hour.significantWeatherCode),
|
||||
humidity: hour.screenRelativeHumidity || null,
|
||||
rain: hour.totalPrecipAmount || 0,
|
||||
snow: hour.totalSnowAmount || 0,
|
||||
precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0),
|
||||
precipitationProbability: hour.probOfPrecipitation || null,
|
||||
feelsLikeTemp: hour.feelsLikeTemp || null
|
||||
});
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Met Office significant weather code to weathericons.css icon
|
||||
* See: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264
|
||||
* @param {number} weatherType - Met Office weather code
|
||||
* @returns {string|null} Weathericons.css icon name or null
|
||||
*/
|
||||
#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[weatherType] || null;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UkMetOfficeDataHubProvider;
|
||||
@@ -0,0 +1,490 @@
|
||||
const Log = require("logger");
|
||||
const { convertKmhToMs, cardinalToDegrees } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
const WEATHER_API_BASE = "https://api.weatherapi.com/v1";
|
||||
|
||||
class WeatherAPIProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: WEATHER_API_BASE,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
apiKey: "",
|
||||
lang: "en",
|
||||
maxEntries: 5,
|
||||
maxNumberOfDays: 5,
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.locationName = null;
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
this.#validateConfig();
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
#validateConfig () {
|
||||
this.config.type = `${this.config.type ?? ""}`.trim().toLowerCase();
|
||||
|
||||
if (this.config.type === "forecast") {
|
||||
this.config.type = "daily";
|
||||
}
|
||||
|
||||
if (!["hourly", "daily", "current"].includes(this.config.type)) {
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (!this.config.apiKey || `${this.config.apiKey}`.trim() === "") {
|
||||
throw new Error("apiKey is required");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(this.config.lat) || !Number.isFinite(this.config.lon)) {
|
||||
throw new Error("Latitude and longitude are required");
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
logContext: "weatherprovider.weatherapi"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weatherapi] Failed to parse JSON:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
let parsedData;
|
||||
|
||||
try {
|
||||
parsedData = this.#parseResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weatherapi] Invalid API response:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Invalid API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(parsedData);
|
||||
break;
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(parsedData);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(parsedData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (this.onDataCallback && weatherData) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[weatherapi] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getQueryParameters () {
|
||||
const maxEntries = Number.isFinite(this.config.maxEntries)
|
||||
? Math.max(1, this.config.maxEntries)
|
||||
: 5;
|
||||
|
||||
const requestedDays = Number.isFinite(this.config.maxNumberOfDays)
|
||||
? Math.max(1, this.config.maxNumberOfDays)
|
||||
: 5;
|
||||
|
||||
const hourlyDays = Math.max(1, Math.ceil(maxEntries / 24));
|
||||
const days = this.config.type === "hourly"
|
||||
? Math.min(14, Math.max(requestedDays, hourlyDays))
|
||||
: this.config.type === "daily"
|
||||
? Math.min(14, requestedDays)
|
||||
: 1;
|
||||
|
||||
const params = {
|
||||
q: `${this.config.lat},${this.config.lon}`,
|
||||
days,
|
||||
lang: this.config.lang,
|
||||
key: this.config.apiKey
|
||||
};
|
||||
|
||||
return Object.keys(params)
|
||||
.filter((key) => params[key] !== undefined && params[key] !== null && `${params[key]}`.trim() !== "")
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
return `${this.config.apiBase}/forecast.json?${this.#getQueryParameters()}`;
|
||||
}
|
||||
|
||||
#parseResponse (responseData) {
|
||||
responseData.location ??= {};
|
||||
responseData.current ??= {};
|
||||
responseData.current.condition ??= {};
|
||||
responseData.forecast ??= {};
|
||||
responseData.forecast.forecastday ??= [];
|
||||
responseData.forecast.forecastday = responseData.forecast.forecastday.map((forecastDay) => ({
|
||||
...forecastDay,
|
||||
astro: forecastDay.astro ?? {},
|
||||
day: forecastDay.day ?? {},
|
||||
hour: forecastDay.hour ?? []
|
||||
}));
|
||||
|
||||
const locationParts = [
|
||||
responseData.location.name,
|
||||
responseData.location.region,
|
||||
responseData.location.country
|
||||
]
|
||||
.map((value) => `${value}`.trim())
|
||||
.filter((value) => value !== "");
|
||||
|
||||
if (locationParts.length > 0) {
|
||||
this.locationName = locationParts.join(", ").trim();
|
||||
}
|
||||
|
||||
if (
|
||||
!responseData.location
|
||||
|| !responseData.current
|
||||
|| !responseData.forecast
|
||||
|| !Array.isArray(responseData.forecast.forecastday)
|
||||
) {
|
||||
throw new Error("Invalid API response");
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
#parseSunDatetime (forecastDay, key) {
|
||||
const timeValue = forecastDay?.astro?.[key];
|
||||
if (!timeValue || !forecastDay?.date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = (/^\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*$/i).exec(timeValue);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hour = parseInt(match[1], 10);
|
||||
const minute = parseInt(match[2], 10);
|
||||
const period = match[3].toUpperCase();
|
||||
|
||||
if (period === "PM" && hour !== 12) hour += 12;
|
||||
if (period === "AM" && hour === 12) hour = 0;
|
||||
|
||||
const date = new Date(`${forecastDay.date}T00:00:00`);
|
||||
date.setHours(hour, minute, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
#toNumber (value) {
|
||||
const number = parseFloat(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
const weather = data.forecast.forecastday[0] ?? {};
|
||||
const current = data.current ?? {};
|
||||
const currentWeather = {
|
||||
date: current.last_updated_epoch ? new Date(current.last_updated_epoch * 1000) : new Date()
|
||||
};
|
||||
|
||||
const humidity = this.#toNumber(current.humidity);
|
||||
if (humidity !== null) currentWeather.humidity = humidity;
|
||||
|
||||
const temperature = this.#toNumber(current.temp_c);
|
||||
if (temperature !== null) currentWeather.temperature = temperature;
|
||||
|
||||
const feelsLikeTemp = this.#toNumber(current.feelslike_c);
|
||||
if (feelsLikeTemp !== null) currentWeather.feelsLikeTemp = feelsLikeTemp;
|
||||
|
||||
const windSpeed = this.#toNumber(current.wind_kph);
|
||||
if (windSpeed !== null) currentWeather.windSpeed = convertKmhToMs(windSpeed);
|
||||
|
||||
const windFromDirection = this.#toNumber(current.wind_degree);
|
||||
if (windFromDirection !== null) currentWeather.windFromDirection = windFromDirection;
|
||||
|
||||
if (current.condition?.code !== undefined) {
|
||||
currentWeather.weatherType = this.#convertWeatherType(current.condition.code, current.is_day === 1);
|
||||
}
|
||||
|
||||
const sunrise = this.#parseSunDatetime(weather, "sunrise");
|
||||
const sunset = this.#parseSunDatetime(weather, "sunset");
|
||||
if (sunrise) currentWeather.sunrise = sunrise;
|
||||
if (sunset) currentWeather.sunset = sunset;
|
||||
|
||||
const minTemperature = this.#toNumber(weather.day?.mintemp_c);
|
||||
if (minTemperature !== null) currentWeather.minTemperature = minTemperature;
|
||||
|
||||
const maxTemperature = this.#toNumber(weather.day?.maxtemp_c);
|
||||
if (maxTemperature !== null) currentWeather.maxTemperature = maxTemperature;
|
||||
|
||||
const snow = this.#toNumber(current.snow_cm);
|
||||
if (snow !== null) currentWeather.snow = snow * 10;
|
||||
|
||||
const rain = this.#toNumber(current.precip_mm);
|
||||
if (rain !== null) currentWeather.rain = rain;
|
||||
|
||||
if (rain !== null || snow !== null) {
|
||||
currentWeather.precipitationAmount = (rain ?? 0) + ((snow ?? 0) * 10);
|
||||
}
|
||||
|
||||
return currentWeather;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
const days = [];
|
||||
const forecastDays = data.forecast.forecastday ?? [];
|
||||
|
||||
for (const forecastDay of forecastDays) {
|
||||
const weather = {};
|
||||
const dayDate = forecastDay.date_epoch
|
||||
? new Date(forecastDay.date_epoch * 1000)
|
||||
: new Date(`${forecastDay.date}T00:00:00`);
|
||||
|
||||
const precipitationProbability = forecastDay.hour?.length > 0
|
||||
? (forecastDay.hour.reduce((sum, hourData) => {
|
||||
const rain = this.#toNumber(hourData.will_it_rain) ?? 0;
|
||||
const snow = this.#toNumber(hourData.will_it_snow) ?? 0;
|
||||
return sum + ((rain + snow) / 2);
|
||||
}, 0) / forecastDay.hour.length) * 100
|
||||
: null;
|
||||
|
||||
const avgWindDegree = forecastDay.hour?.length > 0
|
||||
? forecastDay.hour.reduce((sum, hourData) => {
|
||||
return sum + (this.#toNumber(hourData.wind_degree) ?? 0);
|
||||
}, 0) / forecastDay.hour.length
|
||||
: null;
|
||||
|
||||
weather.date = dayDate;
|
||||
weather.minTemperature = this.#toNumber(forecastDay.day?.mintemp_c);
|
||||
weather.maxTemperature = this.#toNumber(forecastDay.day?.maxtemp_c);
|
||||
weather.weatherType = this.#convertWeatherType(forecastDay.day?.condition?.code, true);
|
||||
|
||||
const maxWind = this.#toNumber(forecastDay.day?.maxwind_kph);
|
||||
if (maxWind !== null) weather.windSpeed = convertKmhToMs(maxWind);
|
||||
|
||||
if (avgWindDegree !== null) {
|
||||
weather.windFromDirection = avgWindDegree;
|
||||
}
|
||||
|
||||
const sunrise = this.#parseSunDatetime(forecastDay, "sunrise");
|
||||
const sunset = this.#parseSunDatetime(forecastDay, "sunset");
|
||||
if (sunrise) weather.sunrise = sunrise;
|
||||
if (sunset) weather.sunset = sunset;
|
||||
|
||||
weather.temperature = this.#toNumber(forecastDay.day?.avgtemp_c);
|
||||
weather.humidity = this.#toNumber(forecastDay.day?.avghumidity);
|
||||
|
||||
const snow = this.#toNumber(forecastDay.day?.totalsnow_cm);
|
||||
if (snow !== null) weather.snow = snow * 10;
|
||||
|
||||
const rain = this.#toNumber(forecastDay.day?.totalprecip_mm);
|
||||
if (rain !== null) weather.rain = rain;
|
||||
|
||||
if (rain !== null || snow !== null) {
|
||||
weather.precipitationAmount = (rain ?? 0) + ((snow ?? 0) * 10);
|
||||
}
|
||||
|
||||
if (precipitationProbability !== null) {
|
||||
weather.precipitationProbability = precipitationProbability;
|
||||
}
|
||||
|
||||
weather.uvIndex = this.#toNumber(forecastDay.day?.uv);
|
||||
|
||||
days.push(weather);
|
||||
|
||||
if (days.length >= this.config.maxEntries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
const hours = [];
|
||||
const nowStart = new Date();
|
||||
nowStart.setMinutes(0, 0, 0);
|
||||
nowStart.setHours(nowStart.getHours() + 1);
|
||||
|
||||
for (const forecastDay of data.forecast.forecastday ?? []) {
|
||||
for (const hourData of forecastDay.hour ?? []) {
|
||||
const date = hourData.time_epoch
|
||||
? new Date(hourData.time_epoch * 1000)
|
||||
: new Date(hourData.time);
|
||||
|
||||
if (date < nowStart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const weather = { date };
|
||||
|
||||
const sunrise = this.#parseSunDatetime(forecastDay, "sunrise");
|
||||
const sunset = this.#parseSunDatetime(forecastDay, "sunset");
|
||||
if (sunrise) weather.sunrise = sunrise;
|
||||
if (sunset) weather.sunset = sunset;
|
||||
|
||||
weather.minTemperature = this.#toNumber(forecastDay.day?.mintemp_c);
|
||||
weather.maxTemperature = this.#toNumber(forecastDay.day?.maxtemp_c);
|
||||
weather.humidity = this.#toNumber(hourData.humidity);
|
||||
|
||||
const windSpeed = this.#toNumber(hourData.wind_kph);
|
||||
if (windSpeed !== null) weather.windSpeed = convertKmhToMs(windSpeed);
|
||||
|
||||
const windDegree = this.#toNumber(hourData.wind_degree);
|
||||
weather.windFromDirection = windDegree !== null
|
||||
? windDegree
|
||||
: cardinalToDegrees(hourData.wind_dir);
|
||||
|
||||
weather.weatherType = this.#convertWeatherType(hourData.condition?.code, hourData.is_day === 1);
|
||||
|
||||
const snow = this.#toNumber(hourData.snow_cm);
|
||||
if (snow !== null) weather.snow = snow * 10;
|
||||
|
||||
weather.temperature = this.#toNumber(hourData.temp_c);
|
||||
weather.precipitationAmount = this.#toNumber(hourData.precip_mm);
|
||||
|
||||
const willRain = this.#toNumber(hourData.will_it_rain) ?? 0;
|
||||
const willSnow = this.#toNumber(hourData.will_it_snow) ?? 0;
|
||||
weather.precipitationProbability = (willRain + willSnow) * 50;
|
||||
|
||||
weather.uvIndex = this.#toNumber(hourData.uv);
|
||||
|
||||
hours.push(weather);
|
||||
|
||||
if (hours.length >= this.config.maxEntries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hours.length >= this.config.maxEntries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#convertWeatherType (weatherCode, isDayTime) {
|
||||
const weatherConditions = {
|
||||
1000: { day: "day-sunny", night: "night-clear" },
|
||||
1003: { day: "day-cloudy", night: "night-alt-cloudy" },
|
||||
1006: { day: "day-cloudy", night: "night-alt-cloudy" },
|
||||
1009: { day: "day-sunny-overcast", night: "night-alt-partly-cloudy" },
|
||||
1030: { day: "day-fog", night: "night-fog" },
|
||||
1063: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1066: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1069: { day: "day-sleet", night: "night-sleet" },
|
||||
1072: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1087: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1114: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1117: { day: "windy", night: "windy" },
|
||||
1135: { day: "day-fog", night: "night-fog" },
|
||||
1147: { day: "day-fog", night: "night-fog" },
|
||||
1150: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1153: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1168: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1171: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1180: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1183: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1186: { day: "day-showers", night: "night-showers" },
|
||||
1189: { day: "day-showers", night: "night-showers" },
|
||||
1192: { day: "day-showers", night: "night-showers" },
|
||||
1195: { day: "day-showers", night: "night-showers" },
|
||||
1198: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1201: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1204: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1207: { day: "day-showers", night: "night-showers" },
|
||||
1210: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1213: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1216: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1219: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1222: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1225: { day: "snowflake-cold", night: "snowflake-cold" },
|
||||
1237: { day: "day-sleet", night: "night-sleet" },
|
||||
1240: { day: "day-sprinkle", night: "night-sprinkle" },
|
||||
1243: { day: "day-showers", night: "night-showers" },
|
||||
1246: { day: "day-showers", night: "night-showers" },
|
||||
1249: { day: "day-showers", night: "night-showers" },
|
||||
1252: { day: "day-showers", night: "night-showers" },
|
||||
1255: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1258: { day: "day-snow-wind", night: "night-snow-wind" },
|
||||
1261: { day: "day-sleet", night: "night-sleet" },
|
||||
1264: { day: "day-sleet", night: "night-sleet" },
|
||||
1273: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1276: { day: "day-thunderstorm", night: "night-thunderstorm" },
|
||||
1279: { day: "day-snow-thunderstorm", night: "night-snow-thunderstorm" },
|
||||
1282: { day: "day-snow-thunderstorm", night: "night-snow-thunderstorm" }
|
||||
};
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(weatherConditions, weatherCode)) {
|
||||
return "na";
|
||||
}
|
||||
|
||||
return weatherConditions[weatherCode][isDayTime ? "day" : "night"];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherAPIProvider;
|
||||
@@ -0,0 +1,292 @@
|
||||
const Log = require("logger");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Weatherbit weather provider
|
||||
* See: https://www.weatherbit.io/
|
||||
*/
|
||||
class WeatherbitProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://api.weatherbit.io/v2.0",
|
||||
apiKey: "",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
initialize () {
|
||||
if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") {
|
||||
Log.error("[weatherbit] No API key configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Weatherbit API key required. Get one at https://www.weatherbit.io/",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
Accept: "application/json"
|
||||
},
|
||||
logContext: "weatherprovider.weatherbit"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weatherbit] Parse error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#getUrl () {
|
||||
const endpoint = this.#getWeatherEndpoint();
|
||||
return `${this.config.apiBase}${endpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`;
|
||||
}
|
||||
|
||||
#getWeatherEndpoint () {
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
return "/forecast/hourly";
|
||||
case "daily":
|
||||
case "forecast":
|
||||
return "/forecast/daily";
|
||||
case "current":
|
||||
default:
|
||||
return "/current";
|
||||
}
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
Log.error("[weatherbit] No usable data received");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "No usable data in API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let weatherData = null;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrent(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateDaily(data);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data);
|
||||
break;
|
||||
default:
|
||||
Log.error(`[weatherbit] Unknown weather type: ${this.config.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (weatherData && this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrent (data) {
|
||||
if (!data.data[0] || typeof data.data[0].temp === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = data.data[0];
|
||||
|
||||
const weather = {
|
||||
date: new Date(current.ts * 1000),
|
||||
temperature: parseFloat(current.temp),
|
||||
humidity: parseFloat(current.rh),
|
||||
windSpeed: parseFloat(current.wind_spd),
|
||||
windFromDirection: current.wind_dir || null,
|
||||
weatherType: this.#convertWeatherType(current.weather.icon),
|
||||
sunrise: null,
|
||||
sunset: null
|
||||
};
|
||||
|
||||
// Parse sunrise/sunset from HH:mm format (already in local time)
|
||||
if (current.sunrise) {
|
||||
const [hours, minutes] = current.sunrise.split(":");
|
||||
const sunrise = new Date(current.ts * 1000);
|
||||
sunrise.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||
weather.sunrise = sunrise;
|
||||
}
|
||||
|
||||
if (current.sunset) {
|
||||
const [hours, minutes] = current.sunset.split(":");
|
||||
const sunset = new Date(current.ts * 1000);
|
||||
sunset.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||
weather.sunset = sunset;
|
||||
}
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
#generateDaily (data) {
|
||||
const days = [];
|
||||
|
||||
for (const forecast of data.data) {
|
||||
days.push({
|
||||
date: new Date(forecast.datetime),
|
||||
minTemperature: forecast.min_temp !== undefined ? parseFloat(forecast.min_temp) : null,
|
||||
maxTemperature: forecast.max_temp !== undefined ? parseFloat(forecast.max_temp) : null,
|
||||
precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0,
|
||||
precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null,
|
||||
weatherType: this.#convertWeatherType(forecast.weather.icon)
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
const hours = [];
|
||||
|
||||
for (const forecast of data.data) {
|
||||
hours.push({
|
||||
date: new Date(forecast.timestamp_local),
|
||||
temperature: forecast.temp !== undefined ? parseFloat(forecast.temp) : null,
|
||||
precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0,
|
||||
precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null,
|
||||
windSpeed: forecast.wind_spd !== undefined ? parseFloat(forecast.wind_spd) : null,
|
||||
windFromDirection: forecast.wind_dir || null,
|
||||
weatherType: this.#convertWeatherType(forecast.weather.icon)
|
||||
});
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Weatherbit icon codes to weathericons.css icons
|
||||
* See: https://www.weatherbit.io/api/codes
|
||||
* @param {string} weatherType - Weatherbit icon code
|
||||
* @returns {string|null} Weathericons.css icon name or null
|
||||
*/
|
||||
#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-showers",
|
||||
d03n: "night-alt-showers",
|
||||
r01d: "day-showers",
|
||||
r01n: "night-alt-showers",
|
||||
r02d: "day-rain",
|
||||
r02n: "night-alt-rain",
|
||||
r03d: "day-rain",
|
||||
r03n: "night-alt-rain",
|
||||
r04d: "day-sprinkle",
|
||||
r04n: "night-alt-sprinkle",
|
||||
r05d: "day-showers",
|
||||
r05n: "night-alt-showers",
|
||||
r06d: "day-showers",
|
||||
r06n: "night-alt-showers",
|
||||
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[weatherType] || null;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherbitProvider;
|
||||
@@ -0,0 +1,302 @@
|
||||
const Log = require("logger");
|
||||
const { convertKmhToMs } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* WeatherFlow weather provider
|
||||
* This class is a provider for WeatherFlow personal weather stations.
|
||||
* Note that the WeatherFlow API does not provide snowfall.
|
||||
*/
|
||||
class WeatherFlowProvider {
|
||||
|
||||
/**
|
||||
* @param {object} config - Provider configuration
|
||||
*/
|
||||
constructor (config) {
|
||||
this.config = config;
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the callbacks for data and errors
|
||||
* @param {(data: object) => void} onDataCallback - Called when new data is available
|
||||
* @param {(error: object) => void} onErrorCallback - Called when an error occurs
|
||||
*/
|
||||
setCallbacks (onDataCallback, onErrorCallback) {
|
||||
this.onDataCallback = onDataCallback;
|
||||
this.onErrorCallback = onErrorCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the provider
|
||||
*/
|
||||
initialize () {
|
||||
if (!this.config.token || this.config.token === "YOUR_API_TOKEN_HERE") {
|
||||
Log.error("[weatherflow] No API token configured. Get one at https://tempestwx.com/");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "WeatherFlow API token required. Get one at https://tempestwx.com/",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.stationid) {
|
||||
Log.error("[weatherflow] No station ID configured");
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "WeatherFlow station ID required",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the HTTP fetcher
|
||||
*/
|
||||
#initializeFetcher () {
|
||||
const url = this.#getUrl();
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
Accept: "application/json"
|
||||
},
|
||||
logContext: "weatherprovider.weatherflow"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
const processed = this.#processData(data);
|
||||
this.onDataCallback(processed);
|
||||
} catch (error) {
|
||||
Log.error("[weatherflow] Failed to parse JSON:", error);
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
// HTTPFetcher already logged the error with logContext
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the URL for API requests
|
||||
* @returns {string} The API URL
|
||||
*/
|
||||
#getUrl () {
|
||||
const base = this.config.apiBase || "https://swd.weatherflow.com/swd/rest/";
|
||||
return `${base}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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the raw API data
|
||||
* @param {object} data - Raw API response
|
||||
* @returns {object} Processed weather data
|
||||
*/
|
||||
#processData (data) {
|
||||
try {
|
||||
let weatherData;
|
||||
if (this.config.type === "current") {
|
||||
weatherData = this.#generateCurrent(data);
|
||||
} else if (this.config.type === "hourly") {
|
||||
weatherData = this.#generateHourly(data);
|
||||
} else {
|
||||
weatherData = this.#generateDaily(data);
|
||||
}
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
Log.error("[weatherflow] Data processing error:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to process weather data",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate current weather data
|
||||
* @param {object} data - API response data
|
||||
* @returns {object} Current weather object
|
||||
*/
|
||||
#generateCurrent (data) {
|
||||
if (!data || !data.current_conditions || !data.forecast || !Array.isArray(data.forecast.daily) || data.forecast.daily.length === 0) {
|
||||
Log.error("[weatherflow] Invalid current weather data structure");
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = data.current_conditions;
|
||||
const daily = data.forecast.daily[0];
|
||||
|
||||
const weather = {
|
||||
date: new Date(),
|
||||
humidity: current.relative_humidity ?? null,
|
||||
temperature: current.air_temperature ?? null,
|
||||
feelsLikeTemp: current.feels_like ?? null,
|
||||
windSpeed: current.wind_avg != null ? convertKmhToMs(current.wind_avg) : null,
|
||||
windFromDirection: current.wind_direction ?? null,
|
||||
weatherType: this.#convertWeatherType(current.icon),
|
||||
precipitationAmount: current.precip_accum_local_day ?? null,
|
||||
precipitationUnits: "mm",
|
||||
precipitationProbability: current.precip_probability ?? null,
|
||||
uvIndex: current.uv || null,
|
||||
sunrise: daily.sunrise ? new Date(daily.sunrise * 1000) : null,
|
||||
sunset: daily.sunset ? new Date(daily.sunset * 1000) : null
|
||||
};
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate forecast data
|
||||
* @param {object} data - API response data
|
||||
* @returns {Array} Array of forecast objects
|
||||
*/
|
||||
#generateDaily (data) {
|
||||
if (!data || !data.forecast || !Array.isArray(data.forecast.daily) || !Array.isArray(data.forecast.hourly)) {
|
||||
Log.error("[weatherflow] Invalid forecast data structure");
|
||||
return [];
|
||||
}
|
||||
|
||||
const days = [];
|
||||
|
||||
for (const forecast of data.forecast.daily) {
|
||||
const weather = {
|
||||
date: new Date(forecast.day_start_local * 1000),
|
||||
minTemperature: forecast.air_temp_low ?? null,
|
||||
maxTemperature: forecast.air_temp_high ?? null,
|
||||
precipitationProbability: forecast.precip_probability ?? null,
|
||||
weatherType: this.#convertWeatherType(forecast.icon),
|
||||
precipitationAmount: 0.0,
|
||||
precipitationUnits: "mm",
|
||||
uvIndex: 0
|
||||
};
|
||||
|
||||
// Build UV and precipitation from hourly data
|
||||
for (const hour of data.forecast.hourly) {
|
||||
const hourDate = new Date(hour.time * 1000);
|
||||
const forecastDate = new Date(forecast.day_start_local * 1000);
|
||||
|
||||
// Compare year, month, and day to ensure correct matching across month boundaries
|
||||
if (hourDate.getFullYear() === forecastDate.getFullYear()
|
||||
&& hourDate.getMonth() === forecastDate.getMonth()
|
||||
&& hourDate.getDate() === forecastDate.getDate()) {
|
||||
weather.uvIndex = Math.max(weather.uvIndex, hour.uv ?? 0);
|
||||
weather.precipitationAmount += hour.precip ?? 0;
|
||||
} else if (hourDate > forecastDate) {
|
||||
// Check if we've moved to the next day
|
||||
const diffMs = hourDate - forecastDate;
|
||||
if (diffMs >= 86400000) break; // 24 hours in ms
|
||||
}
|
||||
}
|
||||
|
||||
days.push(weather);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hourly forecast data
|
||||
* @param {object} data - API response data
|
||||
* @returns {Array} Array of hourly forecast objects
|
||||
*/
|
||||
#generateHourly (data) {
|
||||
if (!data || !data.forecast || !Array.isArray(data.forecast.hourly)) {
|
||||
Log.error("[weatherflow] Invalid hourly data structure");
|
||||
return [];
|
||||
}
|
||||
|
||||
const hours = [];
|
||||
|
||||
for (const hour of data.forecast.hourly) {
|
||||
const weather = {
|
||||
date: new Date(hour.time * 1000),
|
||||
temperature: hour.air_temperature ?? null,
|
||||
feelsLikeTemp: hour.feels_like ?? null,
|
||||
humidity: hour.relative_humidity ?? null,
|
||||
windSpeed: hour.wind_avg != null ? convertKmhToMs(hour.wind_avg) : null,
|
||||
windFromDirection: hour.wind_direction ?? null,
|
||||
weatherType: this.#convertWeatherType(hour.icon),
|
||||
precipitationProbability: hour.precip_probability ?? null,
|
||||
precipitationAmount: hour.precip ?? 0,
|
||||
precipitationUnits: "mm",
|
||||
uvIndex: hour.uv || null
|
||||
};
|
||||
|
||||
hours.push(weather);
|
||||
|
||||
// WeatherFlow provides 10 days of hourly data, trim to 48 hours
|
||||
if (hours.length >= 48) break;
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert weather icon type
|
||||
* @param {string} weatherType - WeatherFlow icon code
|
||||
* @returns {string} Weather icon CSS class
|
||||
*/
|
||||
#convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
"clear-day": "day-sunny",
|
||||
"clear-night": "night-clear",
|
||||
cloudy: "cloudy",
|
||||
foggy: "fog",
|
||||
"partly-cloudy-day": "day-cloudy",
|
||||
"partly-cloudy-night": "night-alt-cloudy",
|
||||
"possibly-rainy-day": "day-rain",
|
||||
"possibly-rainy-night": "night-alt-rain",
|
||||
"possibly-sleet-day": "day-sleet",
|
||||
"possibly-sleet-night": "night-alt-sleet",
|
||||
"possibly-snow-day": "day-snow",
|
||||
"possibly-snow-night": "night-alt-snow",
|
||||
"possibly-thunderstorm-day": "day-thunderstorm",
|
||||
"possibly-thunderstorm-night": "night-alt-thunderstorm",
|
||||
rainy: "rain",
|
||||
sleet: "sleet",
|
||||
snow: "snow",
|
||||
thunderstorm: "thunderstorm",
|
||||
windy: "strong-wind"
|
||||
};
|
||||
|
||||
return weatherTypes[weatherType] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start fetching data
|
||||
*/
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop fetching data
|
||||
*/
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherFlowProvider;
|
||||
@@ -0,0 +1,417 @@
|
||||
const Log = require("logger");
|
||||
const { getSunTimes, isDayTime, getDateString, convertKmhToMs, cardinalToDegrees } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Weather.gov (US National Weather Service)
|
||||
* Note: Only works for US locations, no API key required
|
||||
* https://weather-gov.github.io/api/general-faqs
|
||||
*/
|
||||
class WeatherGovProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://api.weather.gov/points/",
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
this.locationName = null;
|
||||
this.initRetryCount = 0;
|
||||
this.initRetryTimer = null;
|
||||
|
||||
// Weather.gov specific URLs (fetched during initialization)
|
||||
this.forecastURL = null;
|
||||
this.forecastHourlyURL = null;
|
||||
this.forecastGridDataURL = null;
|
||||
this.observationStationsURL = null;
|
||||
this.stationObsURL = null;
|
||||
}
|
||||
|
||||
async initialize () {
|
||||
// Add small random delay to prevent all instances from starting simultaneously
|
||||
// This reduces parallel DNS lookups which can cause EAI_AGAIN errors
|
||||
const staggerDelay = Math.random() * 3000; // 0-3 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, staggerDelay));
|
||||
|
||||
try {
|
||||
await this.#fetchWeatherGovURLs();
|
||||
this.#initializeFetcher();
|
||||
this.initRetryCount = 0; // Reset on success
|
||||
} catch (error) {
|
||||
const errorInfo = this.#categorizeError(error);
|
||||
Log.error(`[weathergov] Initialization failed: ${errorInfo.message}`);
|
||||
|
||||
// Retry on temporary errors (DNS, timeout, network)
|
||||
if (errorInfo.isRetryable && this.initRetryCount < 5) {
|
||||
this.initRetryCount++;
|
||||
const delay = HTTPFetcher.calculateBackoffDelay(this.initRetryCount);
|
||||
Log.info(`[weathergov] Will retry initialization in ${Math.round(delay / 1000)}s (attempt ${this.initRetryCount}/5)`);
|
||||
this.initRetryTimer = setTimeout(() => this.initialize(), delay);
|
||||
} else if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: errorInfo.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#categorizeError (error) {
|
||||
const cause = error.cause || error;
|
||||
const code = cause.code || "";
|
||||
|
||||
if (code === "EAI_AGAIN" || code === "ENOTFOUND") {
|
||||
return {
|
||||
message: "DNS lookup failed for api.weather.gov - check your internet connection",
|
||||
isRetryable: true
|
||||
};
|
||||
}
|
||||
if (code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "ECONNRESET") {
|
||||
return {
|
||||
message: `Network error: ${code} - api.weather.gov may be temporarily unavailable`,
|
||||
isRetryable: true
|
||||
};
|
||||
}
|
||||
if (error.name === "AbortError") {
|
||||
return {
|
||||
message: "Request timeout - api.weather.gov is responding slowly",
|
||||
isRetryable: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: error.message || "Unknown error",
|
||||
isRetryable: false
|
||||
};
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
if (this.initRetryTimer) {
|
||||
clearTimeout(this.initRetryTimer);
|
||||
this.initRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchWeatherGovURLs () {
|
||||
// Step 1: Get grid point data
|
||||
const pointsUrl = `${this.config.apiBase}${this.config.lat},${this.config.lon}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120000); // 120 second timeout - DNS can be slow
|
||||
|
||||
try {
|
||||
const pointsResponse = await fetch(pointsUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/geo+json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!pointsResponse.ok) {
|
||||
throw new Error(`Failed to fetch grid point: HTTP ${pointsResponse.status}`);
|
||||
}
|
||||
|
||||
const pointsData = await pointsResponse.json();
|
||||
|
||||
if (!pointsData || !pointsData.properties) {
|
||||
throw new Error("Invalid grid point data");
|
||||
}
|
||||
|
||||
// Extract location name
|
||||
const relLoc = pointsData.properties.relativeLocation?.properties;
|
||||
if (relLoc) {
|
||||
this.locationName = `${relLoc.city}, ${relLoc.state}`;
|
||||
}
|
||||
|
||||
// Store forecast URLs
|
||||
this.forecastURL = `${pointsData.properties.forecast}?units=si`;
|
||||
this.forecastHourlyURL = `${pointsData.properties.forecastHourly}?units=si`;
|
||||
this.forecastGridDataURL = pointsData.properties.forecastGridData;
|
||||
this.observationStationsURL = pointsData.properties.observationStations;
|
||||
|
||||
// Step 2: Get observation station URL
|
||||
const stationsResponse = await fetch(this.observationStationsURL, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/geo+json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!stationsResponse.ok) {
|
||||
throw new Error(`Failed to fetch observation stations: HTTP ${stationsResponse.status}`);
|
||||
}
|
||||
|
||||
const stationsData = await stationsResponse.json();
|
||||
|
||||
if (!stationsData || !stationsData.features || stationsData.features.length === 0) {
|
||||
throw new Error("No observation stations found");
|
||||
}
|
||||
|
||||
this.stationObsURL = `${stationsData.features[0].id}/observations/latest`;
|
||||
|
||||
Log.log(`[weathergov] Initialized for ${this.locationName}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
let url;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
url = this.stationObsURL;
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
url = this.forecastURL;
|
||||
break;
|
||||
case "hourly":
|
||||
url = this.forecastHourlyURL;
|
||||
break;
|
||||
default:
|
||||
url = this.stationObsURL;
|
||||
}
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
timeout: 60000, // 60 seconds - weather.gov can be slow
|
||||
headers: {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/geo+json",
|
||||
"Cache-Control": "no-cache"
|
||||
},
|
||||
logContext: "weatherprovider.weathergov"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
if (response.status === 304) return;
|
||||
try {
|
||||
const data = await response.json();
|
||||
this.#handleResponse(data);
|
||||
} catch (error) {
|
||||
Log.error("[weathergov] Failed to parse JSON:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#handleResponse (data) {
|
||||
try {
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
if (!data.properties) {
|
||||
throw new Error("Invalid current weather data");
|
||||
}
|
||||
weatherData = this.#generateWeatherObjectFromCurrentWeather(data.properties);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
if (!data.properties || !data.properties.periods) {
|
||||
throw new Error("Invalid forecast data");
|
||||
}
|
||||
weatherData = this.#generateWeatherObjectsFromForecast(data.properties.periods);
|
||||
break;
|
||||
case "hourly":
|
||||
if (!data.properties || !data.properties.periods) {
|
||||
throw new Error("Invalid hourly data");
|
||||
}
|
||||
weatherData = this.#generateWeatherObjectsFromHourly(data.properties.periods);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[weathergov] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#generateWeatherObjectFromCurrentWeather (currentWeatherData) {
|
||||
const current = {};
|
||||
|
||||
current.date = new Date(currentWeatherData.timestamp);
|
||||
current.temperature = currentWeatherData.temperature.value;
|
||||
current.windSpeed = currentWeatherData.windSpeed.value; // Observations are already in m/s
|
||||
current.windFromDirection = currentWeatherData.windDirection.value;
|
||||
current.minTemperature = currentWeatherData.minTemperatureLast24Hours?.value;
|
||||
current.maxTemperature = currentWeatherData.maxTemperatureLast24Hours?.value;
|
||||
current.humidity = Math.round(currentWeatherData.relativeHumidity.value);
|
||||
current.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value;
|
||||
|
||||
// Feels like temperature
|
||||
if (currentWeatherData.heatIndex.value !== null) {
|
||||
current.feelsLikeTemp = currentWeatherData.heatIndex.value;
|
||||
} else if (currentWeatherData.windChill.value !== null) {
|
||||
current.feelsLikeTemp = currentWeatherData.windChill.value;
|
||||
} else {
|
||||
current.feelsLikeTemp = currentWeatherData.temperature.value;
|
||||
}
|
||||
|
||||
// Calculate sunrise/sunset (not provided by weather.gov)
|
||||
const { sunrise, sunset } = getSunTimes(current.date, this.config.lat, this.config.lon);
|
||||
current.sunrise = sunrise;
|
||||
current.sunset = sunset;
|
||||
|
||||
// Determine if daytime
|
||||
const isDay = isDayTime(current.date, current.sunrise, current.sunset);
|
||||
current.weatherType = this.#convertWeatherType(currentWeatherData.textDescription, isDay);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromForecast (forecasts) {
|
||||
const days = [];
|
||||
let minTemp = [];
|
||||
let maxTemp = [];
|
||||
let date = "";
|
||||
let weather = {};
|
||||
|
||||
for (const forecast of forecasts) {
|
||||
const forecastDate = new Date(forecast.startTime);
|
||||
const dateStr = getDateString(forecastDate);
|
||||
|
||||
if (date !== dateStr) {
|
||||
// New day
|
||||
if (date !== "") {
|
||||
weather.minTemperature = Math.min(...minTemp);
|
||||
weather.maxTemperature = Math.max(...maxTemp);
|
||||
days.push(weather);
|
||||
}
|
||||
|
||||
weather = {};
|
||||
minTemp = [];
|
||||
maxTemp = [];
|
||||
date = dateStr;
|
||||
|
||||
weather.date = forecastDate;
|
||||
weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0;
|
||||
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
|
||||
}
|
||||
|
||||
// Update weather type for daytime hours (8am-5pm)
|
||||
const hour = forecastDate.getHours();
|
||||
if (hour >= 8 && hour <= 17) {
|
||||
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
|
||||
}
|
||||
|
||||
minTemp.push(forecast.temperature);
|
||||
maxTemp.push(forecast.temperature);
|
||||
}
|
||||
|
||||
// Last day
|
||||
if (date !== "") {
|
||||
weather.minTemperature = Math.min(...minTemp);
|
||||
weather.maxTemperature = Math.max(...maxTemp);
|
||||
days.push(weather);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
#generateWeatherObjectsFromHourly (forecasts) {
|
||||
const hours = [];
|
||||
|
||||
for (const forecast of forecasts) {
|
||||
const weather = {};
|
||||
|
||||
weather.date = new Date(forecast.startTime);
|
||||
|
||||
// Parse wind speed
|
||||
const windSpeedStr = forecast.windSpeed;
|
||||
let windSpeed = windSpeedStr;
|
||||
if (windSpeedStr.includes(" ")) {
|
||||
windSpeed = windSpeedStr.split(" ")[0];
|
||||
}
|
||||
weather.windSpeed = convertKmhToMs(parseFloat(windSpeed));
|
||||
weather.windFromDirection = cardinalToDegrees(forecast.windDirection);
|
||||
weather.temperature = forecast.temperature;
|
||||
weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0;
|
||||
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
|
||||
|
||||
hours.push(weather);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#convertWeatherType (weatherType, isDaytime) {
|
||||
// https://w1.weather.gov/xml/current_obs/weather.php
|
||||
|
||||
if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) {
|
||||
return isDaytime ? "day-cloudy" : "night-cloudy";
|
||||
} else if (weatherType.includes("Overcast")) {
|
||||
return isDaytime ? "cloudy" : "night-cloudy";
|
||||
} else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) {
|
||||
return "rain-mix";
|
||||
} else if (weatherType.includes("Snow")) {
|
||||
return isDaytime ? "snow" : "night-snow";
|
||||
} else if (weatherType.includes("Thunderstorm")) {
|
||||
return isDaytime ? "thunderstorm" : "night-thunderstorm";
|
||||
} else if (weatherType.includes("Showers")) {
|
||||
return isDaytime ? "showers" : "night-showers";
|
||||
} else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) {
|
||||
return isDaytime ? "rain" : "night-rain";
|
||||
} else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) {
|
||||
return isDaytime ? "cloudy-windy" : "night-alt-cloudy-windy";
|
||||
} else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) {
|
||||
return isDaytime ? "day-sunny" : "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;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeatherGovProvider;
|
||||
@@ -0,0 +1,469 @@
|
||||
const Log = require("logger");
|
||||
const { formatTimezoneOffset, getDateString, validateCoordinates } = require("../provider-utils");
|
||||
const HTTPFetcher = require("#http_fetcher");
|
||||
|
||||
/**
|
||||
* Server-side weather provider for Yr.no (Norwegian Meteorological Institute)
|
||||
* Terms of service: https://developer.yr.no/doc/TermsOfService/
|
||||
*
|
||||
* Note: Minimum update interval is 10 minutes (600000 ms) per API terms
|
||||
*/
|
||||
class YrProvider {
|
||||
constructor (config) {
|
||||
this.config = {
|
||||
apiBase: "https://api.met.no/weatherapi",
|
||||
forecastApiVersion: "2.0",
|
||||
sunriseApiVersion: "3.0",
|
||||
altitude: 0,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
currentForecastHours: 1, // 1, 6 or 12
|
||||
type: "current",
|
||||
updateInterval: 10 * 60 * 1000, // 10 minutes minimum
|
||||
...config
|
||||
};
|
||||
|
||||
// Enforce 10 minute minimum per API terms
|
||||
if (this.config.updateInterval < 600000) {
|
||||
Log.warn("[yr] Minimum update interval is 10 minutes (600000 ms). Adjusting configuration.");
|
||||
this.config.updateInterval = 600000;
|
||||
}
|
||||
|
||||
this.fetcher = null;
|
||||
this.onDataCallback = null;
|
||||
this.onErrorCallback = null;
|
||||
this.locationName = null;
|
||||
|
||||
// Cache for sunrise/sunset data
|
||||
this.stellarData = null;
|
||||
this.stellarDataDate = null;
|
||||
|
||||
// Cache for weather data (If-Modified-Since support)
|
||||
this.weatherCache = {
|
||||
data: null,
|
||||
lastModified: null,
|
||||
expires: null
|
||||
};
|
||||
}
|
||||
|
||||
async initialize () {
|
||||
// Yr.no requires max 4 decimal places
|
||||
validateCoordinates(this.config, 4);
|
||||
await this.#fetchStellarData();
|
||||
this.#initializeFetcher();
|
||||
}
|
||||
|
||||
setCallbacks (onData, onError) {
|
||||
this.onDataCallback = onData;
|
||||
this.onErrorCallback = onError;
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.fetcher) {
|
||||
this.fetcher.clearTimer();
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchStellarData () {
|
||||
const today = getDateString(new Date());
|
||||
|
||||
// Check if we already have today's data
|
||||
if (this.stellarDataDate === today && this.stellarData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = this.#getSunriseUrl();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/json"
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
Log.warn(`[yr] Could not fetch stellar data: HTTP ${response.status}`);
|
||||
this.stellarDataDate = today;
|
||||
} else {
|
||||
// Parse and store the stellar data
|
||||
const data = await response.json();
|
||||
// Transform single-day response into array format expected by #getStellarInfoForDate
|
||||
if (data && data.properties) {
|
||||
this.stellarData = [
|
||||
{
|
||||
date: data.when.interval[0], // ISO date string
|
||||
sunrise: data.properties.sunrise,
|
||||
sunset: data.properties.sunset
|
||||
}
|
||||
];
|
||||
}
|
||||
this.stellarDataDate = today;
|
||||
}
|
||||
} catch (error) {
|
||||
Log.warn("[yr] Failed to fetch stellar data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
#initializeFetcher () {
|
||||
const url = this.#getForecastUrl();
|
||||
|
||||
const headers = {
|
||||
"User-Agent": "MagicMirror",
|
||||
Accept: "application/json"
|
||||
};
|
||||
|
||||
// Add If-Modified-Since header if we have cached data
|
||||
if (this.weatherCache.lastModified) {
|
||||
headers["If-Modified-Since"] = this.weatherCache.lastModified;
|
||||
}
|
||||
|
||||
this.fetcher = new HTTPFetcher(url, {
|
||||
reloadInterval: this.config.updateInterval,
|
||||
headers,
|
||||
logContext: "weatherprovider.yr"
|
||||
});
|
||||
|
||||
this.fetcher.on("response", async (response) => {
|
||||
try {
|
||||
// Handle 304 Not Modified - use cached data
|
||||
if (response.status === 304) {
|
||||
Log.log("[yr] Data not modified, using cache");
|
||||
if (this.weatherCache.data) {
|
||||
this.#handleResponse(this.weatherCache.data, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store cache headers
|
||||
const lastModified = response.headers.get("Last-Modified");
|
||||
const expires = response.headers.get("Expires");
|
||||
|
||||
if (lastModified) {
|
||||
this.weatherCache.lastModified = lastModified;
|
||||
}
|
||||
if (expires) {
|
||||
this.weatherCache.expires = expires;
|
||||
}
|
||||
this.weatherCache.data = data;
|
||||
|
||||
// Update headers for next request
|
||||
if (lastModified && this.fetcher) {
|
||||
this.fetcher.customHeaders["If-Modified-Since"] = lastModified;
|
||||
}
|
||||
|
||||
this.#handleResponse(data, false);
|
||||
} catch (error) {
|
||||
Log.error("[yr] Failed to parse JSON:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: "Failed to parse API response",
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetcher.on("error", (errorInfo) => {
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback(errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #handleResponse (data, fromCache = false) {
|
||||
try {
|
||||
if (!data.properties || !data.properties.timeseries) {
|
||||
throw new Error("Invalid weather data");
|
||||
}
|
||||
|
||||
// Refresh stellar data if needed (new day or using cached weather data)
|
||||
if (fromCache) {
|
||||
await this.#fetchStellarData();
|
||||
}
|
||||
|
||||
let weatherData;
|
||||
|
||||
switch (this.config.type) {
|
||||
case "current":
|
||||
weatherData = this.#generateCurrentWeather(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
weatherData = this.#generateForecast(data);
|
||||
break;
|
||||
case "hourly":
|
||||
weatherData = this.#generateHourly(data);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown weather type: ${this.config.type}`);
|
||||
}
|
||||
|
||||
if (this.onDataCallback) {
|
||||
this.onDataCallback(weatherData);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error("[yr] Error processing weather data:", error);
|
||||
if (this.onErrorCallback) {
|
||||
this.onErrorCallback({
|
||||
message: error.message,
|
||||
translationKey: "MODULE_ERROR_UNSPECIFIED"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#generateCurrentWeather (data) {
|
||||
const now = new Date();
|
||||
const timeseries = data.properties.timeseries;
|
||||
|
||||
// Find closest forecast in the past
|
||||
let forecast = timeseries[0];
|
||||
let closestDiff = Math.abs(now - new Date(forecast.time));
|
||||
|
||||
for (const entry of timeseries) {
|
||||
const entryTime = new Date(entry.time);
|
||||
const diff = now - entryTime;
|
||||
|
||||
if (diff > 0 && diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
forecast = entry;
|
||||
}
|
||||
}
|
||||
|
||||
const forecastXHours = this.#getForecastForXHours(forecast.data);
|
||||
const stellarInfo = this.#getStellarInfoForDate(new Date(forecast.time));
|
||||
|
||||
const current = {};
|
||||
current.date = new Date(forecast.time);
|
||||
current.temperature = forecast.data.instant.details.air_temperature;
|
||||
current.windSpeed = forecast.data.instant.details.wind_speed;
|
||||
current.windFromDirection = forecast.data.instant.details.wind_from_direction;
|
||||
current.humidity = forecast.data.instant.details.relative_humidity;
|
||||
current.weatherType = this.#convertWeatherType(
|
||||
forecastXHours.summary?.symbol_code,
|
||||
stellarInfo ? this.#isDayTime(current.date, stellarInfo) : true
|
||||
);
|
||||
current.precipitationAmount = forecastXHours.details?.precipitation_amount;
|
||||
current.precipitationProbability = forecastXHours.details?.probability_of_precipitation;
|
||||
current.minTemperature = forecastXHours.details?.air_temperature_min;
|
||||
current.maxTemperature = forecastXHours.details?.air_temperature_max;
|
||||
|
||||
if (stellarInfo) {
|
||||
current.sunrise = new Date(stellarInfo.sunrise.time);
|
||||
current.sunset = new Date(stellarInfo.sunset.time);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
#generateForecast (data) {
|
||||
const timeseries = data.properties.timeseries;
|
||||
const dailyData = new Map();
|
||||
|
||||
// Collect all data points for each day
|
||||
for (const entry of timeseries) {
|
||||
const date = new Date(entry.time);
|
||||
const dateStr = getDateString(date);
|
||||
|
||||
if (!dailyData.has(dateStr)) {
|
||||
dailyData.set(dateStr, {
|
||||
date: date,
|
||||
temps: [],
|
||||
precip: [],
|
||||
precipProb: [],
|
||||
symbols: []
|
||||
});
|
||||
}
|
||||
|
||||
const dayData = dailyData.get(dateStr);
|
||||
|
||||
// Collect temperature from instant data
|
||||
if (entry.data.instant?.details?.air_temperature !== undefined) {
|
||||
dayData.temps.push(entry.data.instant.details.air_temperature);
|
||||
}
|
||||
|
||||
// Collect data from forecast periods (prefer longer periods to avoid double-counting)
|
||||
const forecast = entry.data.next_12_hours || entry.data.next_6_hours || entry.data.next_1_hours;
|
||||
if (forecast) {
|
||||
if (forecast.details?.precipitation_amount !== undefined) {
|
||||
dayData.precip.push(forecast.details.precipitation_amount);
|
||||
}
|
||||
if (forecast.details?.probability_of_precipitation !== undefined) {
|
||||
dayData.precipProb.push(forecast.details.probability_of_precipitation);
|
||||
}
|
||||
if (forecast.summary?.symbol_code) {
|
||||
dayData.symbols.push(forecast.summary.symbol_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert collected data to forecast objects
|
||||
const days = [];
|
||||
for (const data of dailyData.values()) {
|
||||
const stellarInfo = this.#getStellarInfoForDate(data.date);
|
||||
|
||||
const dayData = {
|
||||
date: data.date,
|
||||
minTemperature: data.temps.length > 0 ? Math.min(...data.temps) : null,
|
||||
maxTemperature: data.temps.length > 0 ? Math.max(...data.temps) : null,
|
||||
precipitationAmount: data.precip.length > 0 ? Math.max(...data.precip) : null,
|
||||
precipitationProbability: data.precipProb.length > 0 ? Math.max(...data.precipProb) : null,
|
||||
weatherType: data.symbols.length > 0 ? this.#convertWeatherType(data.symbols[0], true) : null
|
||||
};
|
||||
|
||||
if (stellarInfo) {
|
||||
dayData.sunrise = new Date(stellarInfo.sunrise.time);
|
||||
dayData.sunset = new Date(stellarInfo.sunset.time);
|
||||
}
|
||||
|
||||
days.push(dayData);
|
||||
}
|
||||
|
||||
// Sort by date to ensure correct order
|
||||
return days.sort((a, b) => a.date - b.date);
|
||||
}
|
||||
|
||||
#generateHourly (data) {
|
||||
const hours = [];
|
||||
const timeseries = data.properties.timeseries;
|
||||
|
||||
for (const entry of timeseries) {
|
||||
const forecast1h = entry.data.next_1_hours;
|
||||
if (!forecast1h) continue;
|
||||
|
||||
const date = new Date(entry.time);
|
||||
const stellarInfo = this.#getStellarInfoForDate(date);
|
||||
|
||||
const hourly = {
|
||||
date: date,
|
||||
temperature: entry.data.instant.details.air_temperature,
|
||||
windSpeed: entry.data.instant.details.wind_speed,
|
||||
windFromDirection: entry.data.instant.details.wind_from_direction,
|
||||
humidity: entry.data.instant.details.relative_humidity,
|
||||
precipitationAmount: forecast1h.details?.precipitation_amount,
|
||||
precipitationProbability: forecast1h.details?.probability_of_precipitation,
|
||||
weatherType: this.#convertWeatherType(
|
||||
forecast1h.summary?.symbol_code,
|
||||
stellarInfo ? this.#isDayTime(date, stellarInfo) : true
|
||||
)
|
||||
};
|
||||
|
||||
hours.push(hourly);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
#getForecastForXHours (data) {
|
||||
const hours = this.config.currentForecastHours;
|
||||
|
||||
if (hours === 12 && data.next_12_hours) {
|
||||
return data.next_12_hours;
|
||||
} else if (hours === 6 && data.next_6_hours) {
|
||||
return data.next_6_hours;
|
||||
} else if (data.next_1_hours) {
|
||||
return data.next_1_hours;
|
||||
}
|
||||
|
||||
return data.next_6_hours || data.next_12_hours || data.next_1_hours || {};
|
||||
}
|
||||
|
||||
#getStellarInfoForDate (date) {
|
||||
if (!this.stellarData) return null;
|
||||
|
||||
const dateStr = getDateString(date);
|
||||
|
||||
for (const day of this.stellarData) {
|
||||
const dayDate = day.date.split("T")[0];
|
||||
if (dayDate === dateStr) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#isDayTime (date, stellarInfo) {
|
||||
if (!stellarInfo || !stellarInfo.sunrise || !stellarInfo.sunset) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sunrise = new Date(stellarInfo.sunrise.time);
|
||||
const sunset = new Date(stellarInfo.sunset.time);
|
||||
|
||||
return date >= sunrise && date < sunset;
|
||||
}
|
||||
|
||||
#convertWeatherType (symbolCode, isDayTime) {
|
||||
if (!symbolCode) return null;
|
||||
|
||||
// Yr.no uses symbol codes like "clearsky_day", "partlycloudy_night", etc.
|
||||
const symbol = symbolCode.replace(/_day|_night/g, "");
|
||||
|
||||
const mappings = {
|
||||
clearsky: isDayTime ? "day-sunny" : "night-clear",
|
||||
fair: isDayTime ? "day-sunny" : "night-clear",
|
||||
partlycloudy: isDayTime ? "day-cloudy" : "night-cloudy",
|
||||
cloudy: "cloudy",
|
||||
fog: "fog",
|
||||
lightrainshowers: isDayTime ? "day-showers" : "night-showers",
|
||||
rainshowers: isDayTime ? "showers" : "night-showers",
|
||||
heavyrainshowers: isDayTime ? "day-rain" : "night-rain",
|
||||
lightrain: isDayTime ? "day-sprinkle" : "night-sprinkle",
|
||||
rain: isDayTime ? "rain" : "night-rain",
|
||||
heavyrain: isDayTime ? "rain" : "night-rain",
|
||||
lightsleetshowers: isDayTime ? "day-sleet" : "night-sleet",
|
||||
sleetshowers: isDayTime ? "sleet" : "night-sleet",
|
||||
heavysleetshowers: isDayTime ? "sleet" : "night-sleet",
|
||||
lightsleet: isDayTime ? "day-sleet" : "night-sleet",
|
||||
sleet: "sleet",
|
||||
heavysleet: "sleet",
|
||||
lightsnowshowers: isDayTime ? "day-snow" : "night-snow",
|
||||
snowshowers: isDayTime ? "snow" : "night-snow",
|
||||
heavysnowshowers: isDayTime ? "snow" : "night-snow",
|
||||
lightsnow: isDayTime ? "day-snow" : "night-snow",
|
||||
snow: "snow",
|
||||
heavysnow: "snow",
|
||||
lightrainandthunder: isDayTime ? "day-thunderstorm" : "night-thunderstorm",
|
||||
rainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm",
|
||||
heavyrainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm",
|
||||
lightsleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
|
||||
sleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
|
||||
heavysleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
|
||||
lightsnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
||||
snowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
|
||||
heavysnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm"
|
||||
};
|
||||
|
||||
return mappings[symbol] || null;
|
||||
}
|
||||
|
||||
#getForecastUrl () {
|
||||
const { lat, lon, altitude } = this.config;
|
||||
return `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?altitude=${altitude}&lat=${lat}&lon=${lon}`;
|
||||
}
|
||||
|
||||
#getSunriseUrl () {
|
||||
const { lat, lon } = this.config;
|
||||
const today = getDateString(new Date());
|
||||
const offset = formatTimezoneOffset(-new Date().getTimezoneOffset());
|
||||
return `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${today}&offset=${offset}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = YrProvider;
|
||||
@@ -0,0 +1,49 @@
|
||||
.weather .weathericon,
|
||||
.weather .fa-home {
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.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 .humidity-hourly,
|
||||
.weather .uv-index {
|
||||
padding-left: 20px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.weather tr.colored .min-temp {
|
||||
color: #bcddff;
|
||||
}
|
||||
|
||||
.weather tr.colored .max-temp {
|
||||
color: #ff8e99;
|
||||
}
|
||||
|
||||
.weather .type-temp {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/* global WeatherUtils, WeatherObject, formatTime */
|
||||
|
||||
Module.register("weather", {
|
||||
// Default module config.
|
||||
defaults: {
|
||||
weatherProvider: "openweathermap",
|
||||
roundTemp: false,
|
||||
type: "current", // current, forecast, daily (equivalent to forecast), hourly
|
||||
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: "none", // possible options for "current" weather are "none", "wind", "temp", "feelslike" or "below", for "hourly" weather "none" or "true"
|
||||
hideZeroes: false, // hide zeroes (and empty columns) in hourly, currently only for precipitation
|
||||
showIndoorHumidity: false,
|
||||
showIndoorTemperature: false,
|
||||
allowOverrideNotification: 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,
|
||||
forecastDateFormat: "ddd", // format for forecast date display, e.g., "ddd" = Mon, "dddd" = Monday, "D MMM" = 18 Oct
|
||||
hourlyForecastIncrements: 1,
|
||||
themeDir: "",
|
||||
themeCustomScripts: []
|
||||
},
|
||||
|
||||
// Module properties (all providers run server-side)
|
||||
instanceId: null,
|
||||
fetchedLocationName: null,
|
||||
currentWeatherObject: null,
|
||||
weatherForecastArray: null,
|
||||
weatherHourlyArray: null,
|
||||
|
||||
// Can be used by the provider to display location of event if nothing else is specified
|
||||
firstEvent: null,
|
||||
|
||||
getThemeDir () {
|
||||
const td = this.config.themeDir.replace(/\/+$/, "");
|
||||
if (td.length > 0) {
|
||||
return `${td}/`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
// Define required scripts.
|
||||
getStyles () {
|
||||
return ["font-awesome.css", "weather-icons.css", `${this.getThemeDir()}weather.css`];
|
||||
},
|
||||
|
||||
// Return the scripts that are necessary for the weather module.
|
||||
getScripts () {
|
||||
// Only load client-side dependencies for rendering
|
||||
// All providers run server-side via node_helper
|
||||
const resArr = ["moment.js", "weatherutils.js", "weatherobject.js", "suncalc.js"];
|
||||
this.config.themeCustomScripts.forEach((element) => {
|
||||
resArr.push(`${this.getThemeDir()}${element}`);
|
||||
});
|
||||
return resArr;
|
||||
},
|
||||
|
||||
// Override getHeader method.
|
||||
getHeader () {
|
||||
if (this.config.appendLocationNameToHeader) {
|
||||
const locationName = this.fetchedLocationName || "";
|
||||
|
||||
if (this.data.header && locationName) return `${this.data.header} ${locationName}`;
|
||||
else if (locationName) return locationName;
|
||||
}
|
||||
|
||||
return this.data.header ? this.data.header : "";
|
||||
},
|
||||
|
||||
// Start the weather module.
|
||||
start () {
|
||||
moment.locale(this.config.lang);
|
||||
|
||||
if (this.config.useKmh) {
|
||||
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useKmh'. Please switch to windUnits!");
|
||||
this.windUnits = "kmh";
|
||||
} else if (this.config.useBeaufort) {
|
||||
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!");
|
||||
this.windUnits = "beaufort";
|
||||
}
|
||||
if (typeof this.config.showHumidity === "boolean") {
|
||||
Log.warn("[weather] Deprecation warning: Please consider updating showHumidity to the new style (config string).");
|
||||
this.config.showHumidity = this.config.showHumidity ? "wind" : "none";
|
||||
}
|
||||
|
||||
// All providers run server-side: use stable identifier so reconnects don't spawn duplicate HTTPFetchers
|
||||
this.instanceId = this.identifier;
|
||||
|
||||
if (window.initWeatherTheme) window.initWeatherTheme(this);
|
||||
|
||||
Log.log(`[weather] Initializing server-side provider with instance ID: ${this.instanceId}`);
|
||||
|
||||
this.sendSocketNotification("INIT_WEATHER", {
|
||||
instanceId: this.instanceId,
|
||||
weatherProvider: this.config.weatherProvider,
|
||||
...this.config
|
||||
});
|
||||
|
||||
// Server-driven fetching - no client-side scheduling needed
|
||||
|
||||
// Add custom filters
|
||||
this.addFilters();
|
||||
},
|
||||
|
||||
// Cleanup on module hide/suspend
|
||||
stop () {
|
||||
if (this.instanceId) {
|
||||
this.sendSocketNotification("STOP_WEATHER", {
|
||||
instanceId: this.instanceId
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Override notification handler.
|
||||
notificationReceived (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("[weather] 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);
|
||||
} else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) {
|
||||
// Override current weather with data from local sensors
|
||||
if (this.currentWeatherObject) {
|
||||
Object.assign(this.currentWeatherObject, payload);
|
||||
this.updateDom(this.config.animationSpeed);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Handle socket notifications from node_helper
|
||||
socketNotificationReceived (notification, payload) {
|
||||
if (payload.instanceId !== this.instanceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notification === "WEATHER_INITIALIZED") {
|
||||
Log.log(`[weather] Provider initialized, location: ${payload.locationName}`);
|
||||
this.fetchedLocationName = payload.locationName;
|
||||
this.updateDom();
|
||||
// Server-driven fetching - HTTPFetcher will send WEATHER_DATA automatically
|
||||
} else if (notification === "WEATHER_DATA") {
|
||||
this.handleWeatherData(payload);
|
||||
} else if (notification === "WEATHER_ERROR") {
|
||||
Log.error("[weather] Error from node_helper:", payload.error);
|
||||
}
|
||||
},
|
||||
|
||||
handleWeatherData (payload) {
|
||||
const { type, data } = payload;
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert plain objects to WeatherObject instances
|
||||
switch (type) {
|
||||
case "current":
|
||||
this.currentWeatherObject = this.createWeatherObject(data);
|
||||
break;
|
||||
case "forecast":
|
||||
case "daily":
|
||||
this.weatherForecastArray = data.map((d) => this.createWeatherObject(d));
|
||||
break;
|
||||
case "hourly":
|
||||
this.weatherHourlyArray = data.map((d) => this.createWeatherObject(d));
|
||||
break;
|
||||
default:
|
||||
Log.warn(`Unknown weather data type: ${type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
this.updateAvailable();
|
||||
},
|
||||
|
||||
createWeatherObject (data) {
|
||||
const weather = new WeatherObject();
|
||||
Object.assign(weather, {
|
||||
...data,
|
||||
// Convert to moment objects for template compatibility
|
||||
date: data.date ? moment(data.date) : null,
|
||||
sunrise: data.sunrise ? moment(data.sunrise) : null,
|
||||
sunset: data.sunset ? moment(data.sunset) : null
|
||||
});
|
||||
return weather;
|
||||
},
|
||||
|
||||
// Select the template depending on the display type.
|
||||
getTemplate () {
|
||||
switch (this.config.type.toLowerCase()) {
|
||||
case "current":
|
||||
return `${this.getThemeDir()}current.njk`;
|
||||
case "hourly":
|
||||
return `${this.getThemeDir()}hourly.njk`;
|
||||
case "daily":
|
||||
case "forecast":
|
||||
return `${this.getThemeDir()}forecast.njk`;
|
||||
//Make the invalid values use the "Loading..." from forecast
|
||||
default:
|
||||
return `${this.getThemeDir()}forecast.njk`;
|
||||
}
|
||||
},
|
||||
|
||||
// Add all the data to the template.
|
||||
getTemplateData () {
|
||||
const now = new Date();
|
||||
// Filter out past entries, but keep the current hour (e.g. show 0:00 at 0:10).
|
||||
// This ensures consistent behavior across all providers, regardless of whether
|
||||
// a provider filters past entries itself.
|
||||
const startOfHour = new Date(now);
|
||||
startOfHour.setMinutes(0, 0, 0);
|
||||
const upcomingHourlyData = this.weatherHourlyArray
|
||||
?.filter((entry) => entry.date?.valueOf() >= startOfHour.getTime());
|
||||
const hourlySourceData = upcomingHourlyData?.length ? upcomingHourlyData : this.weatherHourlyArray;
|
||||
|
||||
const increment = this.config.hourlyForecastIncrements;
|
||||
const keepByConfiguredIncrement = (_entry, index) => {
|
||||
// Keep the existing offset behavior of hourlyForecastIncrements.
|
||||
return (index + 1) % increment === increment - 1;
|
||||
};
|
||||
|
||||
const hourlyData = hourlySourceData?.filter(keepByConfiguredIncrement);
|
||||
|
||||
return {
|
||||
config: this.config,
|
||||
current: this.currentWeatherObject,
|
||||
forecast: this.weatherForecastArray,
|
||||
hourly: hourlyData,
|
||||
indoor: {
|
||||
humidity: this.indoorHumidity,
|
||||
temperature: this.indoorTemperature
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// What to do when the weather provider has new information available?
|
||||
updateAvailable () {
|
||||
Log.log("[weather] New weather information available.");
|
||||
if (window.updateWeatherTheme) {
|
||||
window.updateWeatherTheme(this);
|
||||
} else {
|
||||
this.updateDom(300);
|
||||
}
|
||||
|
||||
const currentWeather = this.currentWeatherObject;
|
||||
|
||||
if (currentWeather) {
|
||||
this.sendNotification("CURRENTWEATHER_TYPE", { type: currentWeather.weatherType?.replace("-", "_") });
|
||||
}
|
||||
|
||||
const notificationPayload = {
|
||||
currentWeather: this.config.units === "imperial"
|
||||
? WeatherUtils.convertWeatherObjectToImperial(currentWeather?.simpleClone()) ?? null
|
||||
: currentWeather?.simpleClone() ?? null,
|
||||
forecastArray: this.config.units === "imperial"
|
||||
? this.getForecastArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
|
||||
: this.getForecastArray()?.map((ar) => ar.simpleClone()) ?? [],
|
||||
hourlyArray: this.config.units === "imperial"
|
||||
? this.getHourlyArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
|
||||
: this.getHourlyArray()?.map((ar) => ar.simpleClone()) ?? [],
|
||||
locationName: this.fetchedLocationName,
|
||||
providerName: this.config.weatherProvider
|
||||
};
|
||||
|
||||
this.sendNotification("WEATHER_UPDATED", notificationPayload);
|
||||
},
|
||||
|
||||
getForecastArray () {
|
||||
return this.weatherForecastArray;
|
||||
},
|
||||
|
||||
getHourlyArray () {
|
||||
return this.weatherHourlyArray;
|
||||
},
|
||||
|
||||
// scheduleUpdate removed - all providers use server-driven fetching via HTTPFetcher
|
||||
|
||||
roundValue (temperature) {
|
||||
if (temperature === null || temperature === undefined) {
|
||||
return "";
|
||||
}
|
||||
const decimals = this.config.roundTemp ? 0 : 1;
|
||||
const roundValue = parseFloat(temperature).toFixed(decimals);
|
||||
if (roundValue === "NaN") {
|
||||
return "";
|
||||
}
|
||||
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") {
|
||||
if (value === null || value === undefined) {
|
||||
formattedValue = "";
|
||||
} else {
|
||||
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)) {
|
||||
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,133 @@
|
||||
/* global SunCalc, WeatherUtils */
|
||||
|
||||
/**
|
||||
* @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|null} "sunset", "sunrise", or null if sun data unavailable
|
||||
*/
|
||||
nextSunAction (date = moment()) {
|
||||
// Return null if sunrise/sunset data is unavailable
|
||||
if (!this.sunrise || !this.sunset) {
|
||||
return null;
|
||||
}
|
||||
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 () {
|
||||
// Default to daytime if sunrise/sunset data unavailable
|
||||
if (!this.sunrise || !this.sunset) {
|
||||
return true;
|
||||
}
|
||||
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,203 @@
|
||||
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 (value === null || value === undefined || isNaN(value)) {
|
||||
return "";
|
||||
}
|
||||
if (valueUnit === "%") return `${value.toFixed(0)} ${valueUnit}`;
|
||||
|
||||
let convertedValue = value;
|
||||
let conversionUnit;
|
||||
if (outputUnit === "imperial") {
|
||||
convertedValue = this.convertPrecipitationToInch(value, valueUnit);
|
||||
conversionUnit = "in";
|
||||
} else {
|
||||
conversionUnit = valueUnit ? valueUnit : "mm";
|
||||
}
|
||||
|
||||
return `${convertedValue.toFixed(2)} ${conversionUnit}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert precipitation value into inch
|
||||
* @param {number} value the precipitation value for convert
|
||||
* @param {string} valueUnit can be 'mm' or 'cm'
|
||||
* @returns {number} the converted precipitation value
|
||||
*/
|
||||
convertPrecipitationToInch (value, valueUnit) {
|
||||
if (valueUnit && valueUnit.toLowerCase() === "cm") return value * 0.3937007874;
|
||||
else return value * 0.03937007874;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 temp (from degrees C) into metric unit
|
||||
* @param {number} tempInF the temperature in Fahrenheit you want to convert
|
||||
* @returns {number} the converted temperature
|
||||
*/
|
||||
convertTempToMetric (tempInF) {
|
||||
return ((tempInF - 32) * 5) / 9;
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert wind speed into another unit.
|
||||
* @param {number} windInMS the windspeed in meter/sec you want to convert
|
||||
* @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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Taken from https://community.home-assistant.io/t/calculating-apparent-feels-like-temperature/370834/18
|
||||
* @param {number} temperature temperature in degrees Celsius
|
||||
* @param {number} windSpeed wind speed in meter/second
|
||||
* @param {number} humidity relative humidity in percent
|
||||
* @returns {number} the feels like temperature in degrees Celsius
|
||||
*/
|
||||
calculateFeelsLike (temperature, windSpeed, humidity) {
|
||||
const windInMph = this.convertWind(windSpeed, "imperial");
|
||||
const tempInF = this.convertTemp(temperature, "imperial");
|
||||
|
||||
let HI;
|
||||
let WC = tempInF;
|
||||
|
||||
// Calculate wind chill for certain conditions
|
||||
if (tempInF <= 70 && windInMph >= 3) {
|
||||
WC = 35.74 + (0.6215 * tempInF) - 35.75 * Math.pow(windInMph, 0.16) + ((0.4275 * tempInF) * Math.pow(windInMph, 0.16));
|
||||
}
|
||||
|
||||
// Steadman Heat Index Vorberechnung
|
||||
const STEADMAN_HI = 0.5 * (tempInF + 61.0 + ((tempInF - 68.0) * 1.2) + (humidity * 0.094));
|
||||
|
||||
if (STEADMAN_HI >= 80) {
|
||||
// Rothfusz-Komplex
|
||||
const ROTHFUSZ_HI = -42.379 + 2.04901523 * tempInF + 10.14333127 * humidity - 0.22475541 * tempInF * humidity - 0.00683783 * tempInF * tempInF - 0.05481717 * humidity * humidity + 0.00122874 * tempInF * tempInF * humidity + 0.00085282 * tempInF * humidity * humidity - 0.00000199 * tempInF * tempInF * humidity * humidity;
|
||||
|
||||
HI = ROTHFUSZ_HI;
|
||||
|
||||
if (humidity < 13 && tempInF > 80 && tempInF < 112) {
|
||||
const ADJUSTMENT = ((13 - humidity) / 4) * Math.pow(Math.abs(17 - (tempInF - 95)), 0.5) / 17; // sqrt Teil
|
||||
HI = HI - ADJUSTMENT;
|
||||
} else if (humidity > 85 && tempInF > 80 && tempInF < 87) {
|
||||
const ADJUSTMENT = ((humidity - 85) / 10) * ((87 - tempInF) / 5);
|
||||
HI = HI + ADJUSTMENT;
|
||||
}
|
||||
|
||||
} else { HI = STEADMAN_HI; }
|
||||
|
||||
// Feuchte Lastberechnung FL
|
||||
let FL;
|
||||
if (tempInF < 50) { FL = WC; }
|
||||
else if (tempInF >= 50 && tempInF < 70) { FL = ((70 - tempInF) / 20) * WC + ((tempInF - 50) / 20) * HI; }
|
||||
else if (tempInF >= 70) { FL = HI; }
|
||||
|
||||
return this.convertTempToMetric(FL);
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts the Weather Object's values into imperial unit
|
||||
* @param {object} weatherObject the weather object
|
||||
* @returns {object} the weather object with converted values to imperial
|
||||
*/
|
||||
convertWeatherObjectToImperial (weatherObject) {
|
||||
if (!weatherObject || Object.keys(weatherObject).length === 0) return null;
|
||||
|
||||
let imperialWeatherObject = { ...weatherObject };
|
||||
|
||||
if ("feelsLikeTemp" in imperialWeatherObject) imperialWeatherObject.feelsLikeTemp = this.convertTemp(imperialWeatherObject.feelsLikeTemp, "imperial");
|
||||
if ("maxTemperature" in imperialWeatherObject) imperialWeatherObject.maxTemperature = this.convertTemp(imperialWeatherObject.maxTemperature, "imperial");
|
||||
if ("minTemperature" in imperialWeatherObject) imperialWeatherObject.minTemperature = this.convertTemp(imperialWeatherObject.minTemperature, "imperial");
|
||||
if ("precipitationAmount" in imperialWeatherObject) imperialWeatherObject.precipitationAmount = this.convertPrecipitationToInch(imperialWeatherObject.precipitationAmount, imperialWeatherObject.precipitationUnits);
|
||||
if ("temperature" in imperialWeatherObject) imperialWeatherObject.temperature = this.convertTemp(imperialWeatherObject.temperature, "imperial");
|
||||
if ("windSpeed" in imperialWeatherObject) imperialWeatherObject.windSpeed = this.convertWind(imperialWeatherObject.windSpeed, "imperial");
|
||||
|
||||
return imperialWeatherObject;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = WeatherUtils;
|
||||
}
|
||||