From 0ff868647324ef98caca7c9577a8bf0a24af62cf Mon Sep 17 00:00:00 2001 From: matthes-me <52692176+matthes-me@users.noreply.github.com> Date: Tue, 25 Jul 2023 21:26:17 +0200 Subject: [PATCH] docker pulll --- mm/docker-compose.yaml | 24 + mm/mounts/config/config.js | 64 ++ mm/mounts/css/custom.css | 38 + mm/mounts/css/custom.css.sample | 31 + mm/mounts/css/custom.css.save | 29 + mm/mounts/css/iframe.css | 40 + mm/mounts/css/main.css | 241 +++++ mm/mounts/modules/default/alert/README.md | 5 + mm/mounts/modules/default/alert/alert.js | 147 +++ .../modules/default/alert/notificationFx.js | 156 +++ .../modules/default/alert/styles/center.css | 5 + .../modules/default/alert/styles/left.css | 4 + .../default/alert/styles/notificationFx.css | 929 ++++++++++++++++++ .../modules/default/alert/styles/right.css | 4 + .../modules/default/alert/templates/alert.njk | 18 + .../default/alert/templates/notification.njk | 9 + .../default/alert/translations/bg.json | 4 + .../default/alert/translations/da.json | 4 + .../default/alert/translations/de.json | 4 + .../default/alert/translations/en.json | 4 + .../default/alert/translations/es.json | 4 + .../default/alert/translations/fr.json | 4 + .../default/alert/translations/hu.json | 4 + .../default/alert/translations/nl.json | 4 + .../default/alert/translations/ru.json | 4 + .../default/alert/translations/th.json | 4 + mm/mounts/modules/default/calendar/README.md | 6 + .../modules/default/calendar/calendar.css | 24 + .../modules/default/calendar/calendar.js | 856 ++++++++++++++++ .../default/calendar/calendarfetcher.js | 155 +++ .../default/calendar/calendarfetcherutils.js | 605 ++++++++++++ .../modules/default/calendar/calendarutils.js | 117 +++ mm/mounts/modules/default/calendar/debug.js | 43 + .../modules/default/calendar/node_helper.js | 95 ++ .../default/calendar/windowsZones.json | 237 +++++ mm/mounts/modules/default/clock/README.md | 6 + mm/mounts/modules/default/clock/clock.js | 306 ++++++ .../modules/default/clock/clock_styles.css | 93 ++ .../modules/default/clock/faces/face-001.svg | 1 + .../modules/default/clock/faces/face-002.svg | 1 + .../modules/default/clock/faces/face-003.svg | 1 + .../modules/default/clock/faces/face-004.svg | 1 + .../modules/default/clock/faces/face-005.svg | 1 + .../modules/default/clock/faces/face-006.svg | 1 + .../modules/default/clock/faces/face-007.svg | 1 + .../modules/default/clock/faces/face-008.svg | 1 + .../modules/default/clock/faces/face-009.svg | 1 + .../modules/default/clock/faces/face-010.svg | 1 + .../modules/default/clock/faces/face-011.svg | 1 + .../modules/default/clock/faces/face-012.svg | 1 + .../modules/default/compliments/README.md | 6 + .../default/compliments/compliments.js | 181 ++++ mm/mounts/modules/default/defaultmodules.js | 12 + .../modules/default/helloworld/README.md | 5 + .../modules/default/helloworld/helloworld.js | 20 + .../modules/default/helloworld/helloworld.njk | 5 + mm/mounts/modules/default/newsfeed/README.md | 6 + .../modules/default/newsfeed/fullarticle.njk | 3 + .../modules/default/newsfeed/newsfeed.css | 24 + .../modules/default/newsfeed/newsfeed.js | 409 ++++++++ .../modules/default/newsfeed/newsfeed.njk | 93 ++ .../default/newsfeed/newsfeedfetcher.js | 183 ++++ .../modules/default/newsfeed/node_helper.js | 86 ++ .../modules/default/newsfeed/oldconfig.njk | 3 + .../default/updatenotification/README.md | 6 + .../default/updatenotification/git_helper.js | 213 ++++ .../default/updatenotification/node_helper.js | 87 ++ .../updatenotification/updatenotification.css | 3 + .../updatenotification/updatenotification.js | 100 ++ .../updatenotification/updatenotification.njk | 15 + mm/mounts/modules/default/utils.js | 172 ++++ mm/mounts/modules/default/weather/README.md | 5 + mm/mounts/modules/default/weather/current.njk | 89 ++ .../modules/default/weather/forecast.njk | 52 + mm/mounts/modules/default/weather/hourly.njk | 42 + .../default/weather/providers/README.md | 3 + .../default/weather/providers/envcanada.js | 572 +++++++++++ .../default/weather/providers/openmeteo.js | 548 +++++++++++ .../weather/providers/openweathermap.js | 448 +++++++++ .../weather/providers/pirateweather.js | 133 +++ .../modules/default/weather/providers/smhi.js | 334 +++++++ .../default/weather/providers/ukmetoffice.js | 201 ++++ .../weather/providers/ukmetofficedatahub.js | 271 +++++ .../default/weather/providers/weatherbit.js | 208 ++++ .../default/weather/providers/weatherflow.js | 77 ++ .../default/weather/providers/weathergov.js | 367 +++++++ .../modules/default/weather/providers/yr.js | 632 ++++++++++++ mm/mounts/modules/default/weather/weather.css | 49 + mm/mounts/modules/default/weather/weather.js | 297 ++++++ .../modules/default/weather/weatherobject.js | 136 +++ .../default/weather/weatherprovider.js | 168 ++++ .../modules/default/weather/weatherutils.js | 142 +++ npm/data/database.sqlite | Bin 0 -> 102400 bytes npm/data/keys.json | 4 + npm/data/nginx/proxy_host/1.conf | 78 ++ npm/data/nginx/redirection_host/1.conf | 41 + npm/docker-compose.yml.txt | 19 + ontime/docker-compose.yml | 23 + ontime/ontime-styles/override.css | 23 + resteamer/docker-compose.yml | 22 + rssbridge/docker-compose.yaml | 16 + 101 files changed, 10971 insertions(+) create mode 100644 mm/docker-compose.yaml create mode 100644 mm/mounts/config/config.js create mode 100644 mm/mounts/css/custom.css create mode 100644 mm/mounts/css/custom.css.sample create mode 100644 mm/mounts/css/custom.css.save create mode 100644 mm/mounts/css/iframe.css create mode 100644 mm/mounts/css/main.css create mode 100644 mm/mounts/modules/default/alert/README.md create mode 100644 mm/mounts/modules/default/alert/alert.js create mode 100644 mm/mounts/modules/default/alert/notificationFx.js create mode 100644 mm/mounts/modules/default/alert/styles/center.css create mode 100644 mm/mounts/modules/default/alert/styles/left.css create mode 100644 mm/mounts/modules/default/alert/styles/notificationFx.css create mode 100644 mm/mounts/modules/default/alert/styles/right.css create mode 100644 mm/mounts/modules/default/alert/templates/alert.njk create mode 100644 mm/mounts/modules/default/alert/templates/notification.njk create mode 100644 mm/mounts/modules/default/alert/translations/bg.json create mode 100644 mm/mounts/modules/default/alert/translations/da.json create mode 100644 mm/mounts/modules/default/alert/translations/de.json create mode 100644 mm/mounts/modules/default/alert/translations/en.json create mode 100644 mm/mounts/modules/default/alert/translations/es.json create mode 100644 mm/mounts/modules/default/alert/translations/fr.json create mode 100644 mm/mounts/modules/default/alert/translations/hu.json create mode 100644 mm/mounts/modules/default/alert/translations/nl.json create mode 100644 mm/mounts/modules/default/alert/translations/ru.json create mode 100644 mm/mounts/modules/default/alert/translations/th.json create mode 100644 mm/mounts/modules/default/calendar/README.md create mode 100644 mm/mounts/modules/default/calendar/calendar.css create mode 100644 mm/mounts/modules/default/calendar/calendar.js create mode 100644 mm/mounts/modules/default/calendar/calendarfetcher.js create mode 100644 mm/mounts/modules/default/calendar/calendarfetcherutils.js create mode 100644 mm/mounts/modules/default/calendar/calendarutils.js create mode 100644 mm/mounts/modules/default/calendar/debug.js create mode 100644 mm/mounts/modules/default/calendar/node_helper.js create mode 100644 mm/mounts/modules/default/calendar/windowsZones.json create mode 100644 mm/mounts/modules/default/clock/README.md create mode 100644 mm/mounts/modules/default/clock/clock.js create mode 100644 mm/mounts/modules/default/clock/clock_styles.css create mode 100644 mm/mounts/modules/default/clock/faces/face-001.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-002.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-003.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-004.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-005.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-006.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-007.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-008.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-009.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-010.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-011.svg create mode 100644 mm/mounts/modules/default/clock/faces/face-012.svg create mode 100644 mm/mounts/modules/default/compliments/README.md create mode 100644 mm/mounts/modules/default/compliments/compliments.js create mode 100644 mm/mounts/modules/default/defaultmodules.js create mode 100644 mm/mounts/modules/default/helloworld/README.md create mode 100644 mm/mounts/modules/default/helloworld/helloworld.js create mode 100644 mm/mounts/modules/default/helloworld/helloworld.njk create mode 100644 mm/mounts/modules/default/newsfeed/README.md create mode 100644 mm/mounts/modules/default/newsfeed/fullarticle.njk create mode 100644 mm/mounts/modules/default/newsfeed/newsfeed.css create mode 100644 mm/mounts/modules/default/newsfeed/newsfeed.js create mode 100644 mm/mounts/modules/default/newsfeed/newsfeed.njk create mode 100644 mm/mounts/modules/default/newsfeed/newsfeedfetcher.js create mode 100644 mm/mounts/modules/default/newsfeed/node_helper.js create mode 100644 mm/mounts/modules/default/newsfeed/oldconfig.njk create mode 100644 mm/mounts/modules/default/updatenotification/README.md create mode 100644 mm/mounts/modules/default/updatenotification/git_helper.js create mode 100644 mm/mounts/modules/default/updatenotification/node_helper.js create mode 100644 mm/mounts/modules/default/updatenotification/updatenotification.css create mode 100644 mm/mounts/modules/default/updatenotification/updatenotification.js create mode 100644 mm/mounts/modules/default/updatenotification/updatenotification.njk create mode 100644 mm/mounts/modules/default/utils.js create mode 100644 mm/mounts/modules/default/weather/README.md create mode 100644 mm/mounts/modules/default/weather/current.njk create mode 100644 mm/mounts/modules/default/weather/forecast.njk create mode 100644 mm/mounts/modules/default/weather/hourly.njk create mode 100644 mm/mounts/modules/default/weather/providers/README.md create mode 100644 mm/mounts/modules/default/weather/providers/envcanada.js create mode 100644 mm/mounts/modules/default/weather/providers/openmeteo.js create mode 100644 mm/mounts/modules/default/weather/providers/openweathermap.js create mode 100644 mm/mounts/modules/default/weather/providers/pirateweather.js create mode 100644 mm/mounts/modules/default/weather/providers/smhi.js create mode 100644 mm/mounts/modules/default/weather/providers/ukmetoffice.js create mode 100644 mm/mounts/modules/default/weather/providers/ukmetofficedatahub.js create mode 100644 mm/mounts/modules/default/weather/providers/weatherbit.js create mode 100644 mm/mounts/modules/default/weather/providers/weatherflow.js create mode 100644 mm/mounts/modules/default/weather/providers/weathergov.js create mode 100644 mm/mounts/modules/default/weather/providers/yr.js create mode 100644 mm/mounts/modules/default/weather/weather.css create mode 100644 mm/mounts/modules/default/weather/weather.js create mode 100644 mm/mounts/modules/default/weather/weatherobject.js create mode 100644 mm/mounts/modules/default/weather/weatherprovider.js create mode 100644 mm/mounts/modules/default/weather/weatherutils.js create mode 100644 npm/data/database.sqlite create mode 100644 npm/data/keys.json create mode 100644 npm/data/nginx/proxy_host/1.conf create mode 100644 npm/data/nginx/redirection_host/1.conf create mode 100644 npm/docker-compose.yml.txt create mode 100644 ontime/docker-compose.yml create mode 100644 ontime/ontime-styles/override.css create mode 100644 resteamer/docker-compose.yml create mode 100644 rssbridge/docker-compose.yaml diff --git a/mm/docker-compose.yaml b/mm/docker-compose.yaml new file mode 100644 index 0000000..96499a4 --- /dev/null +++ b/mm/docker-compose.yaml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + magicmirror: + container_name: mm + image: karsten13/magicmirror:latest + ports: + - "8080:8080" + volumes: + - ./mounts/config:/opt/magic_mirror/config + - ./mounts/modules:/opt/magic_mirror/modules + - ./mounts/css:/opt/magic_mirror/css + restart: unless-stopped + command: + - npm + - run + - server + networks: + - proxy + +networks: + proxy: + external: true + diff --git a/mm/mounts/config/config.js b/mm/mounts/config/config.js new file mode 100644 index 0000000..309b0ad --- /dev/null +++ b/mm/mounts/config/config.js @@ -0,0 +1,64 @@ +/* MagicMirror² Config Sample + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + * + * For more information on how you can configure this file + * see https://docs.magicmirror.builders/configuration/introduction.html + * and https://docs.magicmirror.builders/modules/configuration.html + * + * You can use environment variables using a `config.js.template` file instead of `config.js` + * which will be converted to `config.js` while starting. For more information + * see https://docs.magicmirror.builders/configuration/introduction.html#enviromnent-variables + */ +let config = { + address: "0.0.0.0", // Address to listen on, can be: + // - "localhost", "127.0.0.1", "::1" to listen on loopback interface + // - another specific IPv4/6 to listen on a specific interface + // - "0.0.0.0", "::" to listen on any interface + // Default, when address config is left out or empty, is "localhost" + port: 8080, + basePath: "/", // The URL path where MagicMirror² is hosted. If you are using a Reverse proxy + // you must set the sub path here. basePath must end with a / + ipWhitelist: [], // Set [] to allow all IP addresses + // or add a specific IPv4 of 192.168.1.5 : + // ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.1.5"], + // or IPv4 range of 192.168.3.0 --> 192.168.3.15 use CIDR format : + // ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.3.0/28"], + + useHttps: false, // Support HTTPS or not, default "false" will use HTTP + httpsPrivateKey: "", // HTTPS private key path, only require when useHttps is true + httpsCertificate: "", // HTTPS Certificate path, only require when useHttps is true + + language: "de", + locale: "de-DE", + logLevel: ["INFO", "LOG", "WARN", "ERROR"], // Add "DEBUG" for even more logging + timeFormat: 24, + units: "metric", + + modules: [ + { + module: "alert", + }, + { + module: "newsfeed", + position: "bottom_left", + + config: { + feeds: [ + { + title: "Mastodon", + url: "http://10.45.0.108:3000/?action=display&bridge=FeedMergeBridge&feed_name=KantineFestival&feed_1=https%3A%2F%2Fmastodon.social%2Ftags%2Fkantinefestival.rss&feed_2=https%3A%2F%2Fmastodon.social%2Ftags%2Fsubbotnik.rss&feed_3=https%3A%2F%2Fmastodon.social%2Ftags%2Fkantinesabot.rss&feed_4=https%3A%2F%2Fmastodon.social%2Ftags%2Fsubbotnikchemnitz.rss&feed_5=https%3A%2F%2Fmastodon.social%2Ftags%2Fchemnitz.rss&feed_6=&feed_7=&feed_8=&feed_9=&feed_10=&limit=&format=Atom" + } + ], + showSourceTitle: true, + showPublishDate: true, + broadcastNewsFeeds: true, + broadcastNewsUpdates: true + } + }, + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") {module.exports = config;} diff --git a/mm/mounts/css/custom.css b/mm/mounts/css/custom.css new file mode 100644 index 0000000..de599d9 --- /dev/null +++ b/mm/mounts/css/custom.css @@ -0,0 +1,38 @@ +:root { + --color-text: #999; + --color-text-dimmed: #666; + --color-text-bright: #fff; + --color-background: #00324D; + --font-primary: "Roboto Condensed"; + --font-secondary: "Roboto"; + --font-size: 20px; + --font-size-small: 0.75rem; + --gap-body-top: 0px; + --gap-body-right: 0px; + --gap-body-bottom: 0px; + --gap-body-left: 0px; + --gap-modules: 60px; +} + +.newsfeed { + color: #eee; + position: relative; + font-size: 30px; + line-height: 20px; + background-color: #084263; + padding-top: 16px; + padding-right: 24px; + padding-bottom: 16px; + padding-left: 24px; + border-radius: 10px; + margin-left: 64px; + font-family: "Montserrat", sans-serif; + +} + + +.region.bottom.left { + width: 843px; + hight: 300px; + margin-bottom: 20px; +} diff --git a/mm/mounts/css/custom.css.sample b/mm/mounts/css/custom.css.sample new file mode 100644 index 0000000..48cc17a --- /dev/null +++ b/mm/mounts/css/custom.css.sample @@ -0,0 +1,31 @@ +/* MagicMirror² Custom CSS Sample + * + * Change color and fonts here. + * + * Beware that properties cannot be unitless, so for example write '--gap-body: 0px;' instead of just '--gap-body: 0;' + * + * MIT Licensed. + */ + +/* Uncomment and adjust accordingly if you want to import another font from the google-fonts-api: */ +/* @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;300;400;700&display=swap'); */ + +:root { + --color-text: #999; + --color-text-dimmed: #666; + --color-text-bright: #fff; + --color-background: black; + + --font-primary: "Roboto Condensed"; + --font-secondary: "Roboto"; + + --font-size: 20px; + --font-size-small: 0.75rem; + + --gap-body-top: 60px; + --gap-body-right: 60px; + --gap-body-bottom: 60px; + --gap-body-left: 60px; + + --gap-modules: 30px; +} diff --git a/mm/mounts/css/custom.css.save b/mm/mounts/css/custom.css.save new file mode 100644 index 0000000..dbfe5e5 --- /dev/null +++ b/mm/mounts/css/custom.css.save @@ -0,0 +1,29 @@ +:root { + --color-text: #999; + --color-text-dimmed: #666; + --color-text-bright: #fff; + --color-background: #00324D; + --font-primary: "Roboto Condensed"; + --font-secondary: "Roboto"; + --font-size: 20px; + --font-size-small: 0.75rem; + --gap-body-top: 0px; + --gap-body-right: 0px; + --gap-body-bottom: 0px; + --gap-body-left: 0px; + --gap-modules: 0px; +} + + +.newsfeed { + color: #202020; + text-align: left; + background-color: #; + border-radius: 10px; + height: calc(var(--font-size) + 6px); + padding: 6px 6px 0; + font-weight: bold; + margin-left: 50%; + width: 50%; + height: 50%; +} diff --git a/mm/mounts/css/iframe.css b/mm/mounts/css/iframe.css new file mode 100644 index 0000000..dbd9615 --- /dev/null +++ b/mm/mounts/css/iframe.css @@ -0,0 +1,40 @@ +body, html { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +#backgroundContainer { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +#backgroundFrame { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform: scale(2048/100); /* Skalieren auf 2048p */ + transform-origin: 0 0; /* Die obere linke Ecke bleibt fixiert */ + border: none; +} + +#textOverlay { + position: absolute; + top: 50%; + left: 5%; + width: 50%; + height: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + color: #ffffff; + font-size: 24px; + +} diff --git a/mm/mounts/css/main.css b/mm/mounts/css/main.css new file mode 100644 index 0000000..0aa5c34 --- /dev/null +++ b/mm/mounts/css/main.css @@ -0,0 +1,241 @@ +:root { + --color-text: #999; + --color-text-dimmed: #666; + --color-text-bright: #fff; + --color-background: #000; + --font-primary: "Roboto Condensed"; + --font-secondary: "Roboto"; + --font-size: 20px; + --font-size-xsmall: 0.75rem; + --font-size-small: 1rem; + --font-size-medium: 1.5rem; + --font-size-large: 3.25rem; + --font-size-xlarge: 3.75rem; + --gap-body-top: 60px; + --gap-body-right: 60px; + --gap-body-bottom: 60px; + --gap-body-left: 60px; + --gap-modules: 30px; +} + +html { + cursor: none; + overflow: hidden; + background: var(--color-background); + user-select: none; + font-size: var(--font-size); +} + +::-webkit-scrollbar { + display: none; +} + +body { + margin: var(--gap-body-top) var(--gap-body-right) var(--gap-body-bottom) var(--gap-body-left); + position: absolute; + height: calc(100% - var(--gap-body-top) - var(--gap-body-bottom)); + width: calc(100% - var(--gap-body-right) - var(--gap-body-left)); + background: var(--color-background); + color: var(--color-text); + font-family: var(--font-primary), sans-serif; + font-weight: 400; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/** + * Default styles. + */ + +.dimmed { + color: var(--color-text-dimmed); +} + +.normal { + color: var(--color-text); +} + +.bright { + color: var(--color-text-bright); +} + +.xsmall { + font-size: var(--font-size-xsmall); + line-height: 1.275; +} + +.small { + font-size: var(--font-size-small); + line-height: 1.25; +} + +.medium { + font-size: var(--font-size-medium); + line-height: 1.225; +} + +.large { + font-size: var(--font-size-large); + line-height: 1; +} + +.xlarge { + font-size: var(--font-size-xlarge); + line-height: 1; + letter-spacing: -3px; +} + +.thin { + font-family: var(--font-secondary), sans-serif; + font-weight: 100; +} + +.light { + font-family: var(--font-primary), sans-serif; + font-weight: 300; +} + +.regular { + font-family: var(--font-primary), sans-serif; + font-weight: 400; +} + +.bold { + font-family: var(--font-primary), sans-serif; + font-weight: 700; +} + +.align-right { + text-align: right; +} + +.align-left { + text-align: left; +} + +header { + text-transform: uppercase; + font-size: var(--font-size-xsmall); + font-family: var(--font-primary), Arial, Helvetica, sans-serif; + font-weight: 400; + border-bottom: 1px solid var(--color-text-dimmed); + line-height: 15px; + padding-bottom: 5px; + margin-bottom: 10px; + color: var(--color-text); +} + +sup { + font-size: 50%; + line-height: 50%; +} + +/** + * Module styles. + */ + +.module { + margin-bottom: var(--gap-modules); +} + +.module.hidden { + pointer-events: none; +} + +.module:not(.hidden) { + pointer-events: auto; +} + +.region.bottom .module { + margin-top: var(--gap-modules); + margin-bottom: 0; +} + +.no-wrap { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pre-line { + white-space: pre-line; +} + +/** + * Region Definitions. + */ + +.region { + position: absolute; +} + +.region.fullscreen { + position: absolute; + inset: calc(-1 * var(--gap-body-top)) calc(-1 * var(--gap-body-right)) calc(-1 * var(--gap-body-bottom)) calc(-1 * var(--gap-body-left)); + pointer-events: none; +} + +.region.right { + right: 0; + text-align: right; +} + +.region.top { + top: 0; +} + +.region.top.center, +.region.bottom.center { + left: 50%; + transform: translateX(-50%); +} + +.region.top.right, +.region.top.left, +.region.top.center { + top: 100%; +} + +.region.bottom { + bottom: 0; +} + +.region.bottom.right, +.region.bottom.center, +.region.bottom.left { + bottom: 100%; +} + +.region.bar { + width: 100%; + text-align: center; +} + +.region.third, +.region.middle.center { + width: 100%; + text-align: center; + transform: translateY(-50%); +} + +.region.upper.third { + top: 33%; +} + +.region.middle.center { + top: 50%; +} + +.region.lower.third { + top: 66%; +} + +.region.left { + text-align: left; +} + +.region table { + width: 100%; + border-spacing: 0; + border-collapse: separate; +} diff --git a/mm/mounts/modules/default/alert/README.md b/mm/mounts/modules/default/alert/README.md new file mode 100644 index 0000000..720adae --- /dev/null +++ b/mm/mounts/modules/default/alert/README.md @@ -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). diff --git a/mm/mounts/modules/default/alert/alert.js b/mm/mounts/modules/default/alert/alert.js new file mode 100644 index 0000000..1bd11d0 --- /dev/null +++ b/mm/mounts/modules/default/alert/alert.js @@ -0,0 +1,147 @@ +/* global NotificationFx */ + +/* MagicMirror² + * Module: alert + * + * By Paul-Vincent Roll https://paulvincentroll.com/ + * MIT Licensed. + */ +Module.register("alert", { + alerts: {}, + + defaults: { + effect: "slide", // scale|slide|genie|jelly|flip|bouncyflip|exploader + alert_effect: "jelly", // scale|slide|genie|jelly|flip|bouncyflip|exploader + display_time: 3500, // time a notification is displayed in seconds + position: "center", + welcome_message: false // shown at startup + }, + + getScripts() { + return ["notificationFx.js"]; + }, + + getStyles() { + return ["font-awesome.css", this.file(`./styles/notificationFx.css`), this.file(`./styles/${this.config.position}.css`)]; + }, + + getTranslations() { + return { + bg: "translations/bg.json", + da: "translations/da.json", + de: "translations/de.json", + en: "translations/en.json", + es: "translations/es.json", + fr: "translations/fr.json", + hu: "translations/hu.json", + nl: "translations/nl.json", + ru: "translations/ru.json", + th: "translations/th.json" + }; + }, + + getTemplate(type) { + return `templates/${type}.njk`; + }, + + async start() { + Log.info(`Starting module: ${this.name}`); + + if (this.config.effect === "slide") { + this.config.effect = `${this.config.effect}-${this.config.position}`; + } + + if (this.config.welcome_message) { + const message = this.config.welcome_message === true ? this.translate("welcome") : this.config.welcome_message; + await this.showNotification({ title: this.translate("sysTitle"), message }); + } + }, + + notificationReceived(notification, payload, sender) { + if (notification === "SHOW_ALERT") { + if (payload.type === "notification") { + this.showNotification(payload); + } else { + this.showAlert(payload, sender); + } + } else if (notification === "HIDE_ALERT") { + this.hideAlert(sender); + } + }, + + async showNotification(notification) { + const message = await this.renderMessage(notification.templateName || "notification", notification); + + new NotificationFx({ + message, + layout: "growl", + effect: this.config.effect, + ttl: notification.timer || this.config.display_time + }).show(); + }, + + async showAlert(alert, sender) { + // If module already has an open alert close it + if (this.alerts[sender.name]) { + this.hideAlert(sender, false); + } + + // Add overlay + if (!Object.keys(this.alerts).length) { + this.toggleBlur(true); + } + + const message = await this.renderMessage(alert.templateName || "alert", alert); + + // Store alert in this.alerts + this.alerts[sender.name] = new NotificationFx({ + message, + effect: this.config.alert_effect, + ttl: alert.timer, + onClose: () => this.hideAlert(sender), + al_no: "ns-alert" + }); + + // Show alert + this.alerts[sender.name].show(); + + // Add timer to dismiss alert and overlay + if (alert.timer) { + setTimeout(() => { + this.hideAlert(sender); + }, alert.timer); + } + }, + + hideAlert(sender, close = true) { + // Dismiss alert and remove from this.alerts + if (this.alerts[sender.name]) { + this.alerts[sender.name].dismiss(close); + delete this.alerts[sender.name]; + // Remove overlay + if (!Object.keys(this.alerts).length) { + this.toggleBlur(false); + } + } + }, + + renderMessage(type, data) { + return new Promise((resolve) => { + this.nunjucksEnvironment().render(this.getTemplate(type), data, function (err, res) { + if (err) { + Log.error("Failed to render alert", err); + } + + resolve(res); + }); + }); + }, + + toggleBlur(add = false) { + const method = add ? "add" : "remove"; + const modules = document.querySelectorAll(".module"); + for (const module of modules) { + module.classList[method]("alert-blur"); + } + } +}); diff --git a/mm/mounts/modules/default/alert/notificationFx.js b/mm/mounts/modules/default/alert/notificationFx.js new file mode 100644 index 0000000..5a7426e --- /dev/null +++ b/mm/mounts/modules/default/alert/notificationFx.js @@ -0,0 +1,156 @@ +/** + * Based on work by + * + * notificationFx.js v1.0.0 + * https://tympanus.net/codrops/ + * + * Licensed under the MIT license. + * https://opensource.org/licenses/mit-license.php + * + * Copyright 2014, Codrops + * https://tympanus.net/codrops/ + * @param {object} window The window object + */ +(function (window) { + /** + * Extend one object with another one + * @param {object} a The object to extend + * @param {object} b The object which extends the other, overwrites existing keys + * @returns {object} The merged object + */ + function extend(a, b) { + for (let key in b) { + if (b.hasOwnProperty(key)) { + a[key] = b[key]; + } + } + return a; + } + + /** + * NotificationFx constructor + * @param {object} options The configuration options + * @class + */ + function NotificationFx(options) { + this.options = extend({}, this.options); + extend(this.options, options); + this._init(); + } + + /** + * NotificationFx options + */ + NotificationFx.prototype.options = { + // element to which the notification will be appended + // defaults to the document.body + wrapper: document.body, + // the message + message: "yo!", + // layout type: growl|attached|bar|other + layout: "growl", + // effects for the specified layout: + // for growl layout: scale|slide|genie|jelly + // for attached layout: flip|bouncyflip + // for other layout: boxspinner|cornerexpand|loadingcircle|thumbslider + // ... + effect: "slide", + // notice, warning, error, success + // will add class ns-type-warning, ns-type-error or ns-type-success + type: "notice", + // if the user doesn´t close the notification then we remove it + // after the following time + ttl: 6000, + al_no: "ns-box", + // callbacks + onClose: function () { + return false; + }, + onOpen: function () { + return false; + } + }; + + /** + * Initialize and cache some vars + */ + NotificationFx.prototype._init = function () { + // create HTML structure + this.ntf = document.createElement("div"); + this.ntf.className = `${this.options.al_no} ns-${this.options.layout} ns-effect-${this.options.effect} ns-type-${this.options.type}`; + let strinner = '
'; + strinner += this.options.message; + strinner += "
"; + 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); diff --git a/mm/mounts/modules/default/alert/styles/center.css b/mm/mounts/modules/default/alert/styles/center.css new file mode 100644 index 0000000..4e8f5e1 --- /dev/null +++ b/mm/mounts/modules/default/alert/styles/center.css @@ -0,0 +1,5 @@ +.ns-box { + margin-left: auto; + margin-right: auto; + text-align: center; +} diff --git a/mm/mounts/modules/default/alert/styles/left.css b/mm/mounts/modules/default/alert/styles/left.css new file mode 100644 index 0000000..86d2746 --- /dev/null +++ b/mm/mounts/modules/default/alert/styles/left.css @@ -0,0 +1,4 @@ +.ns-box { + margin-right: auto; + text-align: left; +} diff --git a/mm/mounts/modules/default/alert/styles/notificationFx.css b/mm/mounts/modules/default/alert/styles/notificationFx.css new file mode 100644 index 0000000..df34075 --- /dev/null +++ b/mm/mounts/modules/default/alert/styles/notificationFx.css @@ -0,0 +1,929 @@ +/* Based on work by https://tympanus.net/codrops/licensing/ */ + +.ns-box { + background-color: rgb(0 0 0 / 93%); + padding: 17px; + line-height: 1.4; + margin-bottom: 10px; + z-index: 1; + font-size: 70%; + position: relative; + display: table; + word-wrap: break-word; + max-width: 100%; + border-width: 1px; + border-radius: 5px; + border-style: solid; + border-color: var(--color-text-dimmed); +} + +.ns-alert { + border-style: solid; + border-color: var(--color-text-bright); + padding: 17px; + line-height: 1.4; + margin-bottom: 10px; + z-index: 3; + color: var(--color-text-bright); + font-size: 70%; + position: fixed; + text-align: center; + right: 0; + left: 0; + margin-right: auto; + margin-left: auto; + top: 40%; + width: 40%; + height: auto; + word-wrap: break-word; + border-radius: 20px; +} + +.alert-blur { + filter: blur(2px) brightness(50%); +} + +[class^="ns-effect-"].ns-growl.ns-hide, +[class*=" ns-effect-"].ns-growl.ns-hide { + animation-direction: reverse; +} + +.ns-effect-flip { + transform-origin: 50% 100%; + backface-visibility: hidden; +} + +.ns-effect-flip.ns-show, +.ns-effect-flip.ns-hide { + animation-name: anim-flip-front; + animation-duration: 0.3s; +} + +.ns-effect-flip.ns-hide { + animation-name: anim-flip-back; +} + +@keyframes anim-flip-front { + 0% { + transform: perspective(1000px) rotate3d(1, 0, 0, -90deg); + } + + 100% { + transform: perspective(1000px); + } +} + +@keyframes anim-flip-back { + 0% { + transform: perspective(1000px) rotate3d(1, 0, 0, 90deg); + } + + 100% { + transform: perspective(1000px); + } +} + +.ns-effect-bouncyflip.ns-show, +.ns-effect-bouncyflip.ns-hide { + animation-name: flip-in-x; + animation-duration: 0.8s; +} + +@keyframes flip-in-x { + 0% { + transform: perspective(400px) rotate3d(1, 0, 0, -90deg); + transition-timing-function: ease-in; + } + + 40% { + transform: perspective(400px) rotate3d(1, 0, 0, 20deg); + transition-timing-function: ease-out; + } + + 60% { + transform: perspective(400px) rotate3d(1, 0, 0, -10deg); + transition-timing-function: ease-in; + opacity: 1; + } + + 80% { + transform: perspective(400px) rotate3d(1, 0, 0, 5deg); + transition-timing-function: ease-out; + } + + 100% { + transform: perspective(400px); + } +} + +.ns-effect-bouncyflip.ns-hide { + animation-name: flip-in-x-simple; + animation-duration: 0.3s; +} + +@keyframes flip-in-x-simple { + 0% { + transform: perspective(400px) rotate3d(1, 0, 0, -90deg); + transition-timing-function: ease-in; + } + + 100% { + transform: perspective(400px); + } +} + +.ns-effect-exploader { + transform-origin: 0 0; +} + +.ns-effect-exploader p { + padding: 0.25em 2em 0.25em 3em; +} + +.ns-effect-exploader.ns-show { + animation-name: anim-load; + animation-duration: 1s; +} + +@keyframes anim-load { + 0% { + opacity: 1; + transform: scale3d(0, 0.3, 1); + } + + 100% { + opacity: 1; + transform: scale3d(1, 1, 1); + } +} + +.ns-effect-exploader.ns-hide { + animation-name: anim-fade; + animation-duration: 0.3s; +} + +.ns-effect-exploader.ns-show .ns-box-inner, +.ns-effect-exploader.ns-show .ns-close { + animation-fill-mode: both; + animation-duration: 0.3s; + animation-delay: 0.6s; +} + +.ns-effect-exploader.ns-show .ns-close { + animation-name: anim-fade; +} + +.ns-effect-exploader.ns-show .ns-box-inner { + animation-name: anim-fade-move; + animation-timing-function: ease-out; +} + +@keyframes anim-fade-move { + 0% { + opacity: 0; + transform: translate3d(0, 10px, 0); + } + + 100% { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes anim-fade { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.ns-effect-scale.ns-show, +.ns-effect-scale.ns-hide { + animation-name: anim-scale; + animation-duration: 0.25s; +} + +@keyframes anim-scale { + 0% { + opacity: 0; + transform: translate3d(0, 40px, 0) scale3d(0.1, 0.6, 1); + } + + 100% { + opacity: 1; + transform: translate3d(0, 0, 0) scale3d(1, 1, 1); + } +} + +.ns-effect-jelly.ns-show { + animation-name: anim-jelly; + animation-duration: 1s; + animation-timing-function: linear; +} + +.ns-effect-jelly.ns-hide { + animation-name: anim-fade; + animation-duration: 0.3s; +} + +@keyframes anim-fade { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes anim-jelly { + 0% { + transform: matrix3d(0.7, 0, 0, 0, 0, 0.7, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 2.083333% { + transform: matrix3d(0.7527, 0, 0, 0, 0, 0.7634, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 4.166667% { + transform: matrix3d(0.8107, 0, 0, 0, 0, 0.8454, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 6.25% { + transform: matrix3d(0.8681, 0, 0, 0, 0, 0.929, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 8.333333% { + transform: matrix3d(0.9204, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 10.416667% { + transform: matrix3d(0.9648, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 12.5% { + transform: matrix3d(1, 0, 0, 0, 0, 1.082, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 14.583333% { + transform: matrix3d(1.0256, 0, 0, 0, 0, 1.0915, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 16.666667% { + transform: matrix3d(1.0423, 0, 0, 0, 0, 1.0845, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 18.75% { + transform: matrix3d(1.051, 0, 0, 0, 0, 1.0667, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 20.833333% { + transform: matrix3d(1.0533, 0, 0, 0, 0, 1.0436, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 22.916667% { + transform: matrix3d(1.0508, 0, 0, 0, 0, 1.0201, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 25% { + transform: matrix3d(1.0449, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 27.083333% { + transform: matrix3d(1.037, 0, 0, 0, 0, 0.9853, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 29.166667% { + transform: matrix3d(1.0283, 0, 0, 0, 0, 0.9769, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 31.25% { + transform: matrix3d(1.0197, 0, 0, 0, 0, 0.9742, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 33.333333% { + transform: matrix3d(1.0119, 0, 0, 0, 0, 0.9762, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 35.416667% { + transform: matrix3d(1.0053, 0, 0, 0, 0, 0.9812, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 37.5% { + transform: matrix3d(1, 0, 0, 0, 0, 0.9877, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 39.583333% { + transform: matrix3d(0.9962, 0, 0, 0, 0, 0.9943, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 41.666667% { + transform: matrix3d(0.9937, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 43.75% { + transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0041, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 45.833333% { + transform: matrix3d(0.992, 0, 0, 0, 0, 1.0065, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 47.916667% { + transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0073, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 50% { + transform: matrix3d(0.9933, 0, 0, 0, 0, 1.0067, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 52.083333% { + transform: matrix3d(0.9945, 0, 0, 0, 0, 1.0053, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 54.166667% { + transform: matrix3d(0.9958, 0, 0, 0, 0, 1.0035, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 56.25% { + transform: matrix3d(0.997, 0, 0, 0, 0, 1.002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 58.333333% { + transform: matrix3d(0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 60.416667% { + transform: matrix3d(0.9992, 0, 0, 0, 0, 0.9989, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 62.5% { + transform: matrix3d(1, 0, 0, 0, 0, 0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 64.583333% { + transform: matrix3d(1.0006, 0, 0, 0, 0, 0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 66.666667% { + transform: matrix3d(1.001, 0, 0, 0, 0, 0.9981, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 68.75% { + transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9985, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 70.833333% { + transform: matrix3d(1.0012, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 72.916667% { + transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9996, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 75% { + transform: matrix3d(1.001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 77.083333% { + transform: matrix3d(1.0008, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 79.166667% { + transform: matrix3d(1.0006, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 81.25% { + transform: matrix3d(1.0004, 0, 0, 0, 0, 1.0006, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 83.333333% { + transform: matrix3d(1.0003, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 85.416667% { + transform: matrix3d(1.0001, 0, 0, 0, 0, 1.0004, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 87.5% { + transform: matrix3d(1, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 89.583333% { + transform: matrix3d(0.9999, 0, 0, 0, 0, 1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 91.666667% { + transform: matrix3d(0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 93.75% { + transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 95.833333% { + transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 97.916667% { + transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 100% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } +} + +.ns-effect-slide-left.ns-show { + animation-name: anim-slide-elastic-left; + animation-duration: 1s; + animation-timing-function: linear; +} + +@keyframes anim-slide-elastic-left { + 0% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1000, 0, 0, 1); + } + + 1.666667% { + transform: matrix3d(1.9293, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -739.2681, 0, 0, 1); + } + + 3.333333% { + transform: matrix3d(1.9699, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -521.8255, 0, 0, 1); + } + + 5% { + transform: matrix3d(1.709, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -349.2612, 0, 0, 1); + } + + 6.666667% { + transform: matrix3d(1.424, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -218.324, 0, 0, 1); + } + + 8.333333% { + transform: matrix3d(1.2107, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -123.2985, 0, 0, 1); + } + + 10% { + transform: matrix3d(1.0817, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -57.5927, 0, 0, 1); + } + + 11.666667% { + transform: matrix3d(1.017, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -14.7237, 0, 0, 1); + } + + 13.333333% { + transform: matrix3d(0.9906, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.1279, 0, 0, 1); + } + + 15% { + transform: matrix3d(0.9848, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 24.8634, 0, 0, 1); + } + + 16.666667% { + transform: matrix3d(0.9872, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.405, 0, 0, 1); + } + + 18.333333% { + transform: matrix3d(0.992, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.7528, 0, 0, 1); + } + + 20% { + transform: matrix3d(0.9954, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 28.1014, 0, 0, 1); + } + + 21.666667% { + transform: matrix3d(0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 23.9827, 0, 0, 1); + } + + 23.333333% { + transform: matrix3d(0.9994, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 19.4075, 0, 0, 1); + } + + 25% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 14.9956, 0, 0, 1); + } + + 26.666667% { + transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.0858, 0, 0, 1); + } + + 28.333333% { + transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 7.8251, 0, 0, 1); + } + + 30% { + transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5.2374, 0, 0, 1); + } + + 31.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3.2739, 0, 0, 1); + } + + 33.333333% { + transform: matrix3d(1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.8489, 0, 0, 1); + } + + 35% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.8636, 0, 0, 1); + } + + 36.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.2208, 0, 0, 1); + } + + 38.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1669, 0, 0, 1); + } + + 40% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3728, 0, 0, 1); + } + + 41.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4559, 0, 0, 1); + } + + 43.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4612, 0, 0, 1); + } + + 45% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.421, 0, 0, 1); + } + + 46.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3596, 0, 0, 1); + } + + 48.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.291, 0, 0, 1); + } + + 50% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.2249, 0, 0, 1); + } + + 51.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1662, 0, 0, 1); + } + + 53.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1173, 0, 0, 1); + } + + 55% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0785, 0, 0, 1); + } + + 56.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0491, 0, 0, 1); + } + + 58.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0277, 0, 0, 1); + } + + 60% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.013, 0, 0, 1); + } + + 61.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0033, 0, 0, 1); + } + + 63.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1); + } + + 65% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0056, 0, 0, 1); + } + + 66.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0068, 0, 0, 1); + } + + 68.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0069, 0, 0, 1); + } + + 70% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0063, 0, 0, 1); + } + + 71.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0054, 0, 0, 1); + } + + 73.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0044, 0, 0, 1); + } + + 75% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0034, 0, 0, 1); + } + + 76.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1); + } + + 78.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0018, 0, 0, 1); + } + + 80% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0012, 0, 0, 1); + } + + 81.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0007, 0, 0, 1); + } + + 83.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0004, 0, 0, 1); + } + + 85% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0002, 0, 0, 1); + } + + 86.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0001, 0, 0, 1); + } + + 88.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); + } + + 90% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); + } + + 91.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); + } + + 93.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); + } + + 95% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); + } + + 96.666667% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); + } + + 98.333333% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); + } + + 100% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } +} + +.ns-effect-slide-left.ns-hide { + animation-name: anim-slide-left; + animation-duration: 0.25s; +} + +@keyframes anim-slide-left { + 0% { + transform: translate3d(-30px, 0, 0) translate3d(-100%, 0, 0); + } + + 100% { + transform: translate3d(0, 0, 0); + } +} + +.ns-effect-slide-right.ns-show { + animation: anim-slide-elastic-right 2000ms linear both; +} + +@keyframes anim-slide-elastic-right { + 0% { + transform: matrix3d(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1000, 0, 0, 1); + } + + 2.15% { + transform: matrix3d(1.486, 0, 0, 0, 0, 0.514, 0, 0, 0, 0, 1, 0, 664.594, 0, 0, 1); + } + + 4.1% { + transform: matrix3d(1.147, 0, 0, 0, 0, 0.853, 0, 0, 0, 0, 1, 0, 419.708, 0, 0, 1); + } + + 4.3% { + transform: matrix3d(1.121, 0, 0, 0, 0, 0.879, 0, 0, 0, 0, 1, 0, 398.136, 0, 0, 1); + } + + 6.46% { + transform: matrix3d(0.948, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 206.714, 0, 0, 1); + } + + 8.11% { + transform: matrix3d(0.908, 0, 0, 0, 0, 1.092, 0, 0, 0, 0, 1, 0, 105.491, 0, 0, 1); + } + + 8.61% { + transform: matrix3d(0.907, 0, 0, 0, 0, 1.093, 0, 0, 0, 0, 1, 0, 81.572, 0, 0, 1); + } + + 12.11% { + transform: matrix3d(0.95, 0, 0, 0, 0, 1.05, 0, 0, 0, 0, 1, 0, -18.434, 0, 0, 1); + } + + 14.16% { + transform: matrix3d(0.979, 0, 0, 0, 0, 1.021, 0, 0, 0, 0, 1, 0, -38.734, 0, 0, 1); + } + + 16.12% { + transform: matrix3d(0.997, 0, 0, 0, 0, 1.003, 0, 0, 0, 0, 1, 0, -43.356, 0, 0, 1); + } + + 19.72% { + transform: matrix3d(1.006, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, -34.155, 0, 0, 1); + } + + 27.23% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -7.839, 0, 0, 1); + } + + 30.83% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1.951, 0, 0, 1); + } + + 38.34% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.037, 0, 0, 1); + } + + 41.99% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.812, 0, 0, 1); + } + + 50% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.159, 0, 0, 1); + } + + 60.56% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.025, 0, 0, 1); + } + + 82.78% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.001, 0, 0, 1); + } + + 100% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } +} + +.ns-effect-slide-right.ns-hide { + animation-name: anim-slide-right; + animation-duration: 0.25s; +} + +@keyframes anim-slide-right { + 0% { + transform: translate3d(30px, 0, 0) translate3d(100%, 0, 0); + } + + 100% { + transform: translate3d(0, 0, 0); + } +} + +.ns-effect-slide-center.ns-show { + animation: anim-slide-elastic-center 2000ms linear both; +} + +@keyframes anim-slide-elastic-center { + 0% { + transform: matrix3d(1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1, 0, 0, -300, 0, 1); + } + + 2.15% { + transform: matrix3d(1, 0, 0, 0, 0, 1.971, 0, 0, 0, 0, 1, 0, 0, -199.378, 0, 1); + } + + 4.1% { + transform: matrix3d(1, 0, 0, 0, 0, 1.294, 0, 0, 0, 0, 1, 0, 0, -125.912, 0, 1); + } + + 4.3% { + transform: matrix3d(1, 0, 0, 0, 0, 1.243, 0, 0, 0, 0, 1, 0, 0, -119.441, 0, 1); + } + + 6.46% { + transform: matrix3d(1, 0, 0, 0, 0, 0.895, 0, 0, 0, 0, 1, 0, 0, -62.014, 0, 1); + } + + 8.11% { + transform: matrix3d(1, 0, 0, 0, 0, 0.817, 0, 0, 0, 0, 1, 0, 0, -31.647, 0, 1); + } + + 8.61% { + transform: matrix3d(1, 0, 0, 0, 0, 0.813, 0, 0, 0, 0, 1, 0, 0, -24.472, 0, 1); + } + + 12.11% { + transform: matrix3d(1, 0, 0, 0, 0, 0.9, 0, 0, 0, 0, 1, 0, 0, 5.53, 0, 1); + } + + 14.16% { + transform: matrix3d(1, 0, 0, 0, 0, 0.959, 0, 0, 0, 0, 1, 0, 0, 11.62, 0, 1); + } + + 16.12% { + transform: matrix3d(1, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, 0, 13.007, 0, 1); + } + + 19.72% { + transform: matrix3d(1, 0, 0, 0, 0, 1.012, 0, 0, 0, 0, 1, 0, 0, 10.247, 0, 1); + } + + 27.23% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 2.352, 0, 1); + } + + 30.83% { + transform: matrix3d(1, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0.585, 0, 1); + } + + 38.34% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.311, 0, 1); + } + + 41.99% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.244, 0, 1); + } + + 50% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.048, 0, 1); + } + + 60.56% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0.007, 0, 1); + } + + 82.78% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + 100% { + transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } +} + +.ns-effect-slide-center.ns-hide { + animation-name: anim-slide-center; + animation-duration: 0.25s; +} + +@keyframes anim-slide-center { + 0% { + transform: translate3d(0, -30px, 0) translate3d(0, -100%, 0); + } + + 100% { + transform: translate3d(0, 0, 0); + } +} + +.ns-effect-genie.ns-show, +.ns-effect-genie.ns-hide { + animation-name: anim-genie; + animation-duration: 0.4s; +} + +@keyframes anim-genie { + 0% { + opacity: 0; + transform: translate3d(0, calc(200% + 30px), 0) scale3d(0, 1, 1); + animation-timing-function: ease-in; + } + + 40% { + opacity: 0.5; + transform: translate3d(0, 0, 0) scale3d(0.02, 1.1, 1); + animation-timing-function: ease-out; + } + + 70% { + opacity: 0.6; + transform: translate3d(0, -40px, 0) scale3d(0.8, 1.1, 1); + } + + 100% { + opacity: 1; + transform: translate3d(0, 0, 0) scale3d(1, 1, 1); + } +} diff --git a/mm/mounts/modules/default/alert/styles/right.css b/mm/mounts/modules/default/alert/styles/right.css new file mode 100644 index 0000000..add9b6f --- /dev/null +++ b/mm/mounts/modules/default/alert/styles/right.css @@ -0,0 +1,4 @@ +.ns-box { + margin-left: auto; + text-align: right; +} diff --git a/mm/mounts/modules/default/alert/templates/alert.njk b/mm/mounts/modules/default/alert/templates/alert.njk new file mode 100644 index 0000000..7349a7a --- /dev/null +++ b/mm/mounts/modules/default/alert/templates/alert.njk @@ -0,0 +1,18 @@ +{% if imageUrl or imageFA %} + {% set imageHeight = imageHeight if imageHeight else "80px" %} + {% if imageUrl %} + + {% else %} + + {% endif %} +
+{% endif %} +{% if title %} + {{ title if titleType == 'text' else title | safe }} +{% endif %} +{% if message %} + {% if title %} +
+ {% endif %} + {{ message if messageType == 'text' else message | safe }} +{% endif %} diff --git a/mm/mounts/modules/default/alert/templates/notification.njk b/mm/mounts/modules/default/alert/templates/notification.njk new file mode 100644 index 0000000..1594ad4 --- /dev/null +++ b/mm/mounts/modules/default/alert/templates/notification.njk @@ -0,0 +1,9 @@ +{% if title %} + {{ title if titleType == 'text' else title | safe }} +{% endif %} +{% if message %} + {% if title %} +
+ {% endif %} + {{ message if messageType == 'text' else message | safe }} +{% endif %} diff --git a/mm/mounts/modules/default/alert/translations/bg.json b/mm/mounts/modules/default/alert/translations/bg.json new file mode 100644 index 0000000..102a0b1 --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/bg.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² нотификация", + "welcome": "Добре дошли, стартирането беше успешно" +} diff --git a/mm/mounts/modules/default/alert/translations/da.json b/mm/mounts/modules/default/alert/translations/da.json new file mode 100644 index 0000000..406d2ff --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/da.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² Notifikation", + "welcome": "Velkommen, modulet er succesfuldt startet!" +} diff --git a/mm/mounts/modules/default/alert/translations/de.json b/mm/mounts/modules/default/alert/translations/de.json new file mode 100644 index 0000000..3ae5238 --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/de.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² Benachrichtigung", + "welcome": "Willkommen, Start war erfolgreich!" +} diff --git a/mm/mounts/modules/default/alert/translations/en.json b/mm/mounts/modules/default/alert/translations/en.json new file mode 100644 index 0000000..ea3319b --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/en.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² Notification", + "welcome": "Welcome, start was successful!" +} diff --git a/mm/mounts/modules/default/alert/translations/es.json b/mm/mounts/modules/default/alert/translations/es.json new file mode 100644 index 0000000..a2f7472 --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/es.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² Notificaciones", + "welcome": "Bienvenido, ¡se iniciado correctamente!" +} diff --git a/mm/mounts/modules/default/alert/translations/fr.json b/mm/mounts/modules/default/alert/translations/fr.json new file mode 100644 index 0000000..a6d3c10 --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/fr.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² Notification", + "welcome": "Bienvenue, le démarrage a été un succès!" +} diff --git a/mm/mounts/modules/default/alert/translations/hu.json b/mm/mounts/modules/default/alert/translations/hu.json new file mode 100644 index 0000000..90ab35d --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/hu.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² értesítés", + "welcome": "Üdvözöljük, indulás sikeres!" +} diff --git a/mm/mounts/modules/default/alert/translations/nl.json b/mm/mounts/modules/default/alert/translations/nl.json new file mode 100644 index 0000000..59a480b --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/nl.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² Notificatie", + "welcome": "Welkom, Succesvol gestart!" +} diff --git a/mm/mounts/modules/default/alert/translations/ru.json b/mm/mounts/modules/default/alert/translations/ru.json new file mode 100644 index 0000000..4ddc86c --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/ru.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "MagicMirror² Уведомление", + "welcome": "Добро пожаловать, старт был успешным!" +} diff --git a/mm/mounts/modules/default/alert/translations/th.json b/mm/mounts/modules/default/alert/translations/th.json new file mode 100644 index 0000000..a1894bf --- /dev/null +++ b/mm/mounts/modules/default/alert/translations/th.json @@ -0,0 +1,4 @@ +{ + "sysTitle": "การแจ้งเตือน MagicMirror²", + "welcome": "ยินดีต้อนรับ การเริ่มต้นสำเร็จแล้ว!" +} diff --git a/mm/mounts/modules/default/calendar/README.md b/mm/mounts/modules/default/calendar/README.md new file mode 100644 index 0000000..1527595 --- /dev/null +++ b/mm/mounts/modules/default/calendar/README.md @@ -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). diff --git a/mm/mounts/modules/default/calendar/calendar.css b/mm/mounts/modules/default/calendar/calendar.css new file mode 100644 index 0000000..f8e3bd7 --- /dev/null +++ b/mm/mounts/modules/default/calendar/calendar.css @@ -0,0 +1,24 @@ +.calendar .symbol { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding-left: 0; + padding-right: 10px; + font-size: var(--font-size-small); +} + +.calendar .symbol span { + padding-top: 4px; +} + +.calendar .title { + padding-left: 0; + padding-right: 0; + vertical-align: top; +} + +.calendar .time { + padding-left: 30px; + text-align: right; + vertical-align: top; +} diff --git a/mm/mounts/modules/default/calendar/calendar.js b/mm/mounts/modules/default/calendar/calendar.js new file mode 100644 index 0000000..3343d34 --- /dev/null +++ b/mm/mounts/modules/default/calendar/calendar.js @@ -0,0 +1,856 @@ +/* global CalendarUtils, cloneObject */ + +/* MagicMirror² + * Module: Calendar + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +Module.register("calendar", { + // Define module defaults + defaults: { + maximumEntries: 10, // Total Maximum Entries + maximumNumberOfDays: 365, + limitDays: 0, // Limit the number of days shown, 0 = no limit + pastDaysCount: 0, + displaySymbol: true, + defaultSymbol: "calendar-alt", // Fontawesome Symbol see https://fontawesome.com/cheatsheet?from=io + defaultSymbolClassName: "fas fa-fw fa-", + showLocation: false, + displayRepeatingCountTitle: false, + defaultRepeatingCountTitle: "", + maxTitleLength: 25, + maxLocationTitleLength: 25, + wrapEvents: false, // Wrap events to multiple lines breaking at maxTitleLength + wrapLocationEvents: false, + maxTitleLines: 3, + maxEventTitleLines: 3, + fetchInterval: 60 * 60 * 1000, // Update every hour + animationSpeed: 2000, + fade: true, + urgency: 7, + timeFormat: "relative", + dateFormat: "MMM Do", + dateEndFormat: "LT", + fullDayEventDateFormat: "MMM Do", + showEnd: false, + getRelative: 6, + fadePoint: 0.25, // Start on 1/4th of the list. + hidePrivate: false, + hideOngoing: false, + hideTime: false, + showTimeToday: false, + colored: false, + customEvents: [], // Array of {keyword: "", symbol: "", color: ""} where Keyword is a regexp and symbol/color are to be applied for matched + tableClass: "small", + calendars: [ + { + symbol: "calendar-alt", + url: "https://www.calendarlabs.com/templates/ical/US-Holidays.ics" + } + ], + titleReplace: { + "De verjaardag van ": "", + "'s birthday": "" + }, + locationTitleReplace: { + "street ": "" + }, + broadcastEvents: true, + excludedEvents: [], + sliceMultiDayEvents: false, + broadcastPastEvents: false, + nextDaysRelative: false, + selfSignedCert: false, + coloredText: false, + coloredBorder: false, + coloredSymbol: false, + coloredBackground: false, + limitDaysNeverSkip: false, + flipDateHeaderTitle: false + }, + + requiresVersion: "2.1.0", + + // Define required scripts. + getStyles: function () { + return ["calendar.css", "font-awesome.css"]; + }, + + // Define required scripts. + getScripts: function () { + return ["calendarutils.js", "moment.js"]; + }, + + // Define required translations. + getTranslations: function () { + // The translations for the default modules are defined in the core translation files. + // Therefore we can just return false. Otherwise we should have returned a dictionary. + // If you're trying to build your own module including translations, check out the documentation. + return false; + }, + + // Override start method. + start: function () { + const ONE_MINUTE = 60 * 1000; + + Log.info(`Starting module: ${this.name}`); + + if (this.config.colored) { + Log.warn("Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!"); + this.config.coloredText = true; + this.config.coloredSymbol = true; + } + if (this.config.coloredSymbolOnly) { + Log.warn("Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!"); + this.config.coloredText = false; + this.config.coloredSymbol = true; + } + + // Set locale. + moment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat)); + + // clear data holder before start + this.calendarData = {}; + + // indicate no data available yet + this.loaded = false; + + this.config.calendars.forEach((calendar) => { + calendar.url = calendar.url.replace("webcal://", "http://"); + + const calendarConfig = { + maximumEntries: calendar.maximumEntries, + maximumNumberOfDays: calendar.maximumNumberOfDays, + pastDaysCount: calendar.pastDaysCount, + broadcastPastEvents: calendar.broadcastPastEvents, + selfSignedCert: calendar.selfSignedCert + }; + + if (calendar.symbolClass === "undefined" || calendar.symbolClass === null) { + calendarConfig.symbolClass = ""; + } + if (calendar.titleClass === "undefined" || calendar.titleClass === null) { + calendarConfig.titleClass = ""; + } + if (calendar.timeClass === "undefined" || calendar.timeClass === null) { + calendarConfig.timeClass = ""; + } + + // we check user and password here for backwards compatibility with old configs + if (calendar.user && calendar.pass) { + Log.warn("Deprecation warning: Please update your calendar authentication configuration."); + Log.warn("https://github.com/MichMich/MagicMirror/tree/v2.1.2/modules/default/calendar#calendar-authentication-options"); + calendar.auth = { + user: calendar.user, + pass: calendar.pass + }; + } + + // tell helper to start a fetcher for this calendar + // fetcher till cycle + this.addCalendar(calendar.url, calendar.auth, calendarConfig); + }); + + // Refresh the DOM every minute if needed: When using relative date format for events that start + // or end in less than an hour, the date shows minute granularity and we want to keep that accurate. + setTimeout(() => { + setInterval(() => { + this.updateDom(1); + }, ONE_MINUTE); + }, ONE_MINUTE - (new Date() % ONE_MINUTE)); + }, + + // Override socket notification handler. + socketNotificationReceived: function (notification, payload) { + if (notification === "FETCH_CALENDAR") { + this.sendSocketNotification(notification, { url: payload.url, id: this.identifier }); + } + + if (this.identifier !== payload.id) { + return; + } + + if (notification === "CALENDAR_EVENTS") { + if (this.hasCalendarURL(payload.url)) { + this.calendarData[payload.url] = payload.events; + this.error = null; + this.loaded = true; + + if (this.config.broadcastEvents) { + this.broadcastEvents(); + } + } + } else if (notification === "CALENDAR_ERROR") { + let error_message = this.translate(payload.error_type); + this.error = this.translate("MODULE_CONFIG_ERROR", { MODULE_NAME: this.name, ERROR: error_message }); + this.loaded = true; + } + + this.updateDom(this.config.animationSpeed); + }, + + // Override dom generator. + getDom: function () { + const ONE_SECOND = 1000; // 1,000 milliseconds + const ONE_MINUTE = ONE_SECOND * 60; + const ONE_HOUR = ONE_MINUTE * 60; + const ONE_DAY = ONE_HOUR * 24; + + const events = this.createEventList(true); + const wrapper = document.createElement("table"); + wrapper.className = this.config.tableClass; + + if (this.error) { + wrapper.innerHTML = this.error; + wrapper.className = `${this.config.tableClass} dimmed`; + return wrapper; + } + + if (events.length === 0) { + wrapper.innerHTML = this.loaded ? this.translate("EMPTY") : this.translate("LOADING"); + wrapper.className = `${this.config.tableClass} dimmed`; + return wrapper; + } + + let currentFadeStep = 0; + let startFade; + let fadeSteps; + + if (this.config.fade && this.config.fadePoint < 1) { + if (this.config.fadePoint < 0) { + this.config.fadePoint = 0; + } + startFade = events.length * this.config.fadePoint; + fadeSteps = events.length - startFade; + } + + let lastSeenDate = ""; + + events.forEach((event, index) => { + const dateAsString = moment(event.startDate, "x").format(this.config.dateFormat); + if (this.config.timeFormat === "dateheaders") { + if (lastSeenDate !== dateAsString) { + const dateRow = document.createElement("tr"); + dateRow.className = "dateheader normal"; + if (event.today) dateRow.className += " today"; + else if (event.dayBeforeYesterday) dateRow.className += " dayBeforeYesterday"; + else if (event.yesterday) dateRow.className += " yesterday"; + else if (event.tomorrow) dateRow.className += " tomorrow"; + else if (event.dayAfterTomorrow) dateRow.className += " dayAfterTomorrow"; + + const dateCell = document.createElement("td"); + dateCell.colSpan = "3"; + dateCell.innerHTML = dateAsString; + dateCell.style.paddingTop = "10px"; + dateRow.appendChild(dateCell); + wrapper.appendChild(dateRow); + + if (this.config.fade && index >= startFade) { + //fading + currentFadeStep = index - startFade; + dateRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; + } + + lastSeenDate = dateAsString; + } + } + + const eventWrapper = document.createElement("tr"); + + if (this.config.coloredText) { + eventWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; + } + + if (this.config.coloredBackground) { + eventWrapper.style.backgroundColor = this.colorForUrl(event.url, true); + } + + if (this.config.coloredBorder) { + eventWrapper.style.borderColor = this.colorForUrl(event.url, false); + } + + eventWrapper.className = "event-wrapper normal event"; + if (event.today) eventWrapper.className += " today"; + else if (event.dayBeforeYesterday) eventWrapper.className += " dayBeforeYesterday"; + else if (event.yesterday) eventWrapper.className += " yesterday"; + else if (event.tomorrow) eventWrapper.className += " tomorrow"; + else if (event.dayAfterTomorrow) eventWrapper.className += " dayAfterTomorrow"; + + const symbolWrapper = document.createElement("td"); + + if (this.config.displaySymbol) { + if (this.config.coloredSymbol) { + symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; + } + + const symbolClass = this.symbolClassForUrl(event.url); + symbolWrapper.className = `symbol align-right ${symbolClass}`; + + const symbols = this.symbolsForEvent(event); + symbols.forEach((s, index) => { + const symbol = document.createElement("span"); + symbol.className = s; + if (index > 0) { + symbol.style.paddingLeft = "5px"; + } + symbolWrapper.appendChild(symbol); + }); + eventWrapper.appendChild(symbolWrapper); + } else if (this.config.timeFormat === "dateheaders") { + const blankCell = document.createElement("td"); + blankCell.innerHTML = "   "; + eventWrapper.appendChild(blankCell); + } + + const titleWrapper = document.createElement("td"); + let repeatingCountTitle = ""; + + if (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) { + repeatingCountTitle = this.countTitleForUrl(event.url); + + if (repeatingCountTitle !== "") { + const thisYear = new Date(parseInt(event.startDate)).getFullYear(), + yearDiff = thisYear - event.firstYear; + + repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`; + } + } + + // Color events if custom color is specified + if (this.config.customEvents.length > 0) { + for (let ev in this.config.customEvents) { + if (typeof this.config.customEvents[ev].color !== "undefined" && this.config.customEvents[ev].color !== "") { + let needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); + if (needle.test(event.title)) { + // Respect parameter ColoredSymbolOnly also for custom events + if (this.config.coloredText) { + eventWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; + titleWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; + } + if (this.config.displaySymbol && this.config.coloredSymbol) { + symbolWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; + } + break; + } + } + } + } + + const transformedTitle = CalendarUtils.titleTransform(event.title, this.config.titleReplace); + titleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle; + + const titleClass = this.titleClassForUrl(event.url); + + if (!this.config.coloredText) { + titleWrapper.className = `title bright ${titleClass}`; + } else { + titleWrapper.className = `title ${titleClass}`; + } + + if (this.config.timeFormat === "dateheaders") { + if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); + + if (event.fullDayEvent) { + titleWrapper.colSpan = "2"; + titleWrapper.classList.add("align-left"); + } else { + const timeWrapper = document.createElement("td"); + timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`; + timeWrapper.style.paddingLeft = "2px"; + timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; + timeWrapper.innerHTML = moment(event.startDate, "x").format("LT"); + + // Add endDate to dataheaders if showEnd is enabled + if (this.config.showEnd) { + timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(moment(event.endDate, "x").format("LT"))}`; + } + + eventWrapper.appendChild(timeWrapper); + + if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); + } + if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); + } else { + const timeWrapper = document.createElement("td"); + + eventWrapper.appendChild(titleWrapper); + const now = new Date(); + + if (this.config.timeFormat === "absolute") { + // Use dateFormat + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.dateFormat)); + // Add end time if showEnd + if (this.config.showEnd) { + timeWrapper.innerHTML += "-"; + timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.dateEndFormat)); + } + // For full day events we use the fullDayEventDateFormat + if (event.fullDayEvent) { + //subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day + event.endDate -= ONE_SECOND; + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat)); + } else if (this.config.getRelative > 0 && event.startDate < now) { + // Ongoing and getRelative is set + timeWrapper.innerHTML = CalendarUtils.capFirst( + this.translate("RUNNING", { + fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: moment(event.endDate, "x").fromNow(true) + }) + ); + } else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * ONE_DAY) { + // Within urgency days + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow()); + } + if (event.fullDayEvent && this.config.nextDaysRelative) { + // Full days events within the next two days + if (event.today) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); + } else if (event.yesterday) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); + } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); + } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) { + if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); + } + } + } + } else { + // Show relative times + if (event.startDate >= now || (event.fullDayEvent && event.today)) { + // Use relative time + if (!this.config.hideTime && !event.fullDayEvent) { + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat })); + } else { + timeWrapper.innerHTML = CalendarUtils.capFirst( + moment(event.startDate, "x").calendar(null, { + sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`, + nextDay: `[${this.translate("TOMORROW")}]`, + nextWeek: "dddd", + sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat + }) + ); + } + if (event.fullDayEvent) { + // Full days events within the next two days + if (event.today) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); + } else if (event.dayBeforeYesterday) { + if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY")); + } + } else if (event.yesterday) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); + } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); + } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) { + if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); + } + } + } else if (event.startDate - now < this.config.getRelative * ONE_HOUR) { + // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow()); + } + } else { + // Ongoing event + timeWrapper.innerHTML = CalendarUtils.capFirst( + this.translate("RUNNING", { + fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: moment(event.endDate, "x").fromNow(true) + }) + ); + } + } + timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`; + eventWrapper.appendChild(timeWrapper); + } + + // Create fade effect. + if (index >= startFade) { + currentFadeStep = index - startFade; + eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; + } + wrapper.appendChild(eventWrapper); + + if (this.config.showLocation) { + if (event.location !== false) { + const locationRow = document.createElement("tr"); + locationRow.className = "event-wrapper-location normal xsmall light"; + if (event.today) locationRow.className += " today"; + else if (event.dayBeforeYesterday) locationRow.className += " dayBeforeYesterday"; + else if (event.yesterday) locationRow.className += " yesterday"; + else if (event.tomorrow) locationRow.className += " tomorrow"; + else if (event.dayAfterTomorrow) locationRow.className += " dayAfterTomorrow"; + + if (this.config.displaySymbol) { + const symbolCell = document.createElement("td"); + locationRow.appendChild(symbolCell); + } + + if (this.config.coloredText) { + locationRow.style.cssText = `color:${this.colorForUrl(event.url, false)}`; + } + + if (this.config.coloredBackground) { + locationRow.style.backgroundColor = this.colorForUrl(event.url, true); + } + + if (this.config.coloredBorder) { + locationRow.style.borderColor = this.colorForUrl(event.url, false); + } + + const descCell = document.createElement("td"); + descCell.className = "location"; + descCell.colSpan = "2"; + + const transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace); + descCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines); + locationRow.appendChild(descCell); + + wrapper.appendChild(locationRow); + + if (index >= startFade) { + currentFadeStep = index - startFade; + locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; + } + } + } + }); + + return wrapper; + }, + + /** + * Checks if this config contains the calendar url. + * @param {string} url The calendar url + * @returns {boolean} True if the calendar config contains the url, False otherwise + */ + hasCalendarURL: function (url) { + for (const calendar of this.config.calendars) { + if (calendar.url === url) { + return true; + } + } + + return false; + }, + + /** + * Creates the sorted list of all events. + * @param {boolean} limitNumberOfEntries Whether to filter returned events for display. + * @returns {object[]} Array with events. + */ + createEventList: function (limitNumberOfEntries) { + const ONE_SECOND = 1000; // 1,000 milliseconds + const ONE_MINUTE = ONE_SECOND * 60; + const ONE_HOUR = ONE_MINUTE * 60; + const ONE_DAY = ONE_HOUR * 24; + + const now = new Date(); + const today = moment().startOf("day"); + const future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + let events = []; + + for (const calendarUrl in this.calendarData) { + const calendar = this.calendarData[calendarUrl]; + let remainingEntries = this.maximumEntriesForUrl(calendarUrl); + let maxPastDaysCompare = now - this.maximumPastDaysForUrl(calendarUrl) * ONE_DAY; + for (const e in calendar) { + const event = JSON.parse(JSON.stringify(calendar[e])); // clone object + + if (this.config.hidePrivate && event.class === "PRIVATE") { + // do not add the current event, skip it + continue; + } + if (limitNumberOfEntries) { + if (event.endDate < maxPastDaysCompare) { + continue; + } + if (this.config.hideOngoing && event.startDate < now) { + continue; + } + if (this.listContainsEvent(events, event)) { + continue; + } + if (--remainingEntries < 0) { + break; + } + } + event.url = calendarUrl; + event.today = event.startDate >= today && event.startDate < today + ONE_DAY; + event.dayBeforeYesterday = event.startDate >= today - ONE_DAY * 2 && event.startDate < today - ONE_DAY; + event.yesterday = event.startDate >= today - ONE_DAY && event.startDate < today; + event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY; + event.dayAfterTomorrow = !event.tomorrow && event.startDate >= today + ONE_DAY * 2 && event.startDate < today + 3 * ONE_DAY; + + /* if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days, + * otherwise, esp. in dateheaders mode it is not clear how long these events are. + */ + const maxCount = Math.ceil((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / ONE_DAY) + 1; + if (this.config.sliceMultiDayEvents && maxCount > 1) { + const splitEvents = []; + let midnight = moment(event.startDate, "x").clone().startOf("day").add(1, "day").format("x"); + let count = 1; + while (event.endDate > midnight) { + const thisEvent = JSON.parse(JSON.stringify(event)); // clone object + thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + ONE_DAY; + thisEvent.tomorrow = !thisEvent.today && thisEvent.startDate >= today + ONE_DAY && thisEvent.startDate < today + 2 * ONE_DAY; + thisEvent.endDate = midnight; + thisEvent.title += ` (${count}/${maxCount})`; + splitEvents.push(thisEvent); + + event.startDate = midnight; + count += 1; + midnight = moment(midnight, "x").add(1, "day").format("x"); // next day + } + // Last day + event.title += ` (${count}/${maxCount})`; + event.today += event.startDate >= today && event.startDate < today + ONE_DAY; + event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY; + splitEvents.push(event); + + for (let splitEvent of splitEvents) { + if (splitEvent.endDate > now && splitEvent.endDate <= future) { + events.push(splitEvent); + } + } + } else { + events.push(event); + } + } + } + + events.sort(function (a, b) { + return a.startDate - b.startDate; + }); + + if (!limitNumberOfEntries) { + return events; + } + + // Limit the number of days displayed + // If limitDays is set > 0, limit display to that number of days + if (this.config.limitDays > 0) { + let newEvents = []; + let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD"); + let days = 0; + for (const ev of events) { + let eventDate = moment(ev.startDate, "x").format("YYYYMMDD"); + // if date of event is later than lastdate + // check if we already are showing max unique days + if (eventDate > lastDate) { + // if the only entry in the first day is a full day event that day is not counted as unique + if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) { + days--; + } + days++; + if (days > this.config.limitDays) { + continue; + } else { + lastDate = eventDate; + } + } + newEvents.push(ev); + } + events = newEvents; + } + + return events.slice(0, this.config.maximumEntries); + }, + + listContainsEvent: function (eventList, event) { + for (const evt of eventList) { + if (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate)) { + return true; + } + } + return false; + }, + + /** + * Requests node helper to add calendar url. + * @param {string} url The calendar url to add + * @param {object} auth The authentication method and credentials + * @param {object} calendarConfig The config of the specific calendar + */ + addCalendar: function (url, auth, calendarConfig) { + this.sendSocketNotification("ADD_CALENDAR", { + id: this.identifier, + url: url, + excludedEvents: calendarConfig.excludedEvents || this.config.excludedEvents, + maximumEntries: calendarConfig.maximumEntries || this.config.maximumEntries, + maximumNumberOfDays: calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays, + pastDaysCount: calendarConfig.pastDaysCount || this.config.pastDaysCount, + fetchInterval: calendarConfig.fetchInterval || this.config.fetchInterval, + symbolClass: calendarConfig.symbolClass, + titleClass: calendarConfig.titleClass, + timeClass: calendarConfig.timeClass, + auth: auth, + broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents, + selfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert + }); + }, + + /** + * Retrieves the symbols for a specific event. + * @param {object} event Event to look for. + * @returns {string[]} The symbols + */ + symbolsForEvent: function (event) { + let symbols = this.getCalendarPropertyAsArray(event.url, "symbol", this.config.defaultSymbol); + + if (event.recurringEvent === true && this.hasCalendarProperty(event.url, "recurringSymbol")) { + symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "recurringSymbol", this.config.defaultSymbol), symbols); + } + + if (event.fullDayEvent === true && this.hasCalendarProperty(event.url, "fullDaySymbol")) { + symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "fullDaySymbol", this.config.defaultSymbol), symbols); + } + + // If custom symbol is set, replace event symbol + for (let ev of this.config.customEvents) { + if (typeof ev.symbol !== "undefined" && ev.symbol !== "") { + let needle = new RegExp(ev.keyword, "gi"); + if (needle.test(event.title)) { + // Get the default prefix for this class name and add to the custom symbol provided + const className = this.getCalendarProperty(event.url, "symbolClassName", this.config.defaultSymbolClassName); + symbols[0] = className + ev.symbol; + break; + } + } + } + + return symbols; + }, + + mergeUnique: function (arr1, arr2) { + return arr1.concat( + arr2.filter(function (item) { + return arr1.indexOf(item) === -1; + }) + ); + }, + + /** + * Retrieves the symbolClass for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The class to be used for the symbols of the calendar + */ + symbolClassForUrl: function (url) { + return this.getCalendarProperty(url, "symbolClass", ""); + }, + + /** + * Retrieves the titleClass for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The class to be used for the title of the calendar + */ + titleClassForUrl: function (url) { + return this.getCalendarProperty(url, "titleClass", ""); + }, + + /** + * Retrieves the timeClass for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The class to be used for the time of the calendar + */ + timeClassForUrl: function (url) { + return this.getCalendarProperty(url, "timeClass", ""); + }, + + /** + * Retrieves the calendar name for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The name of the calendar + */ + calendarNameForUrl: function (url) { + return this.getCalendarProperty(url, "name", ""); + }, + + /** + * Retrieves the color for a specific calendar url. + * @param {string} url The calendar url + * @param {boolean} isBg Determines if we fetch the bgColor or not + * @returns {string} The color + */ + colorForUrl: function (url, isBg) { + return this.getCalendarProperty(url, isBg ? "bgColor" : "color", "#fff"); + }, + + /** + * Retrieves the count title for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The title + */ + countTitleForUrl: function (url) { + return this.getCalendarProperty(url, "repeatingCountTitle", this.config.defaultRepeatingCountTitle); + }, + + /** + * Retrieves the maximum entry count for a specific calendar url. + * @param {string} url The calendar url + * @returns {number} The maximum entry count + */ + maximumEntriesForUrl: function (url) { + return this.getCalendarProperty(url, "maximumEntries", this.config.maximumEntries); + }, + + /** + * Retrieves the maximum count of past days which events of should be displayed for a specific calendar url. + * @param {string} url The calendar url + * @returns {number} The maximum past days count + */ + maximumPastDaysForUrl: function (url) { + return this.getCalendarProperty(url, "pastDaysCount", this.config.pastDaysCount); + }, + + /** + * Helper method to retrieve the property for a specific calendar url. + * @param {string} url The calendar url + * @param {string} property The property to look for + * @param {string} defaultValue The value if the property is not found + * @returns {*} The property + */ + getCalendarProperty: function (url, property, defaultValue) { + for (const calendar of this.config.calendars) { + if (calendar.url === url && calendar.hasOwnProperty(property)) { + return calendar[property]; + } + } + + return defaultValue; + }, + + getCalendarPropertyAsArray: function (url, property, defaultValue) { + let p = this.getCalendarProperty(url, property, defaultValue); + if (property === "symbol" || property === "recurringSymbol" || property === "fullDaySymbol") { + const className = this.getCalendarProperty(url, "symbolClassName", this.config.defaultSymbolClassName); + p = className + p; + } + + if (!(p instanceof Array)) p = [p]; + return p; + }, + + hasCalendarProperty: function (url, property) { + return !!this.getCalendarProperty(url, property, undefined); + }, + + /** + * Broadcasts the events to all other modules for reuse. + * The all events available in one array, sorted on startdate. + */ + broadcastEvents: function () { + const eventList = this.createEventList(false); + for (const event of eventList) { + event.symbol = this.symbolsForEvent(event); + event.calendarName = this.calendarNameForUrl(event.url); + event.color = this.colorForUrl(event.url, false); + delete event.url; + } + + this.sendNotification("CALENDAR_EVENTS", eventList); + } +}); diff --git a/mm/mounts/modules/default/calendar/calendarfetcher.js b/mm/mounts/modules/default/calendar/calendarfetcher.js new file mode 100644 index 0000000..c7b6296 --- /dev/null +++ b/mm/mounts/modules/default/calendar/calendarfetcher.js @@ -0,0 +1,155 @@ +/* MagicMirror² + * Node Helper: Calendar - CalendarFetcher + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ + +const https = require("https"); +const digest = require("digest-fetch"); +const ical = require("node-ical"); +const fetch = require("fetch"); +const Log = require("logger"); +const NodeHelper = require("node_helper"); +const CalendarFetcherUtils = require("./calendarfetcherutils"); + +/** + * + * @param {string} url The url of the calendar to fetch + * @param {number} reloadInterval Time in ms the calendar is fetched again + * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. + * @param {number} maximumEntries The maximum number of events fetched. + * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. + * @param {object} auth The object containing options for authentication against the calendar. + * @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too + * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. + * @class + */ +const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) { + let reloadTimer = null; + let events = []; + + let fetchFailedCallback = function () {}; + let eventsReceivedCallback = function () {}; + + /** + * Initiates calendar fetch. + */ + const fetchCalendar = () => { + clearTimeout(reloadTimer); + reloadTimer = null; + const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]); + let fetcher = null; + let httpsAgent = null; + let headers = { + "User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}` + }; + + if (selfSignedCert) { + httpsAgent = new https.Agent({ + rejectUnauthorized: false + }); + } + if (auth) { + if (auth.method === "bearer") { + headers.Authorization = `Bearer ${auth.pass}`; + } else if (auth.method === "digest") { + fetcher = new digest(auth.user, auth.pass).fetch(url, { headers: headers, agent: httpsAgent }); + } else { + headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`; + } + } + if (fetcher === null) { + fetcher = fetch(url, { headers: headers, agent: httpsAgent }); + } + + fetcher + .then(NodeHelper.checkFetchStatus) + .then((response) => response.text()) + .then((responseData) => { + let data = []; + + try { + data = ical.parseICS(responseData); + Log.debug(`parsed data=${JSON.stringify(data)}`); + events = CalendarFetcherUtils.filterEvents(data, { + excludedEvents, + includePastEvents, + maximumEntries, + maximumNumberOfDays + }); + } catch (error) { + fetchFailedCallback(this, error); + scheduleTimer(); + return; + } + this.broadcastEvents(); + scheduleTimer(); + }) + .catch((error) => { + fetchFailedCallback(this, error); + scheduleTimer(); + }); + }; + + /** + * Schedule the timer for the next update. + */ + const scheduleTimer = function () { + clearTimeout(reloadTimer); + reloadTimer = setTimeout(function () { + fetchCalendar(); + }, reloadInterval); + }; + + /* public methods */ + + /** + * Initiate fetchCalendar(); + */ + this.startFetch = function () { + fetchCalendar(); + }; + + /** + * Broadcast the existing events. + */ + this.broadcastEvents = function () { + Log.info(`Calendar-Fetcher: Broadcasting ${events.length} events.`); + eventsReceivedCallback(this); + }; + + /** + * Sets the on success callback + * @param {Function} callback The on success callback. + */ + this.onReceive = function (callback) { + eventsReceivedCallback = callback; + }; + + /** + * Sets the on error callback + * @param {Function} callback The on error callback. + */ + this.onError = function (callback) { + fetchFailedCallback = callback; + }; + + /** + * Returns the url of this fetcher. + * @returns {string} The url of this fetcher. + */ + this.url = function () { + return url; + }; + + /** + * Returns current available events for this fetcher. + * @returns {object[]} The current available events for this fetcher. + */ + this.events = function () { + return events; + }; +}; + +module.exports = CalendarFetcher; diff --git a/mm/mounts/modules/default/calendar/calendarfetcherutils.js b/mm/mounts/modules/default/calendar/calendarfetcherutils.js new file mode 100644 index 0000000..d425f0a --- /dev/null +++ b/mm/mounts/modules/default/calendar/calendarfetcherutils.js @@ -0,0 +1,605 @@ +/* MagicMirror² + * Calendar Fetcher Util Methods + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ + +/** + * @external Moment + */ +const path = require("path"); +const moment = require("moment"); +const zoneTable = require(path.join(__dirname, "windowsZones.json")); +const Log = require("../../../js/logger"); + +const CalendarFetcherUtils = { + /** + * Calculate the time correction, either dst/std or full day in cases where + * utc time is day before plus offset + * @param {object} event the event which needs adjustment + * @param {Date} date the date on which this event happens + * @returns {number} the necessary adjustment in hours + */ + calculateTimezoneAdjustment: function (event, date) { + let adjustHours = 0; + // if a timezone was specified + if (!event.start.tz) { + Log.debug(" if no tz, guess based on now"); + event.start.tz = moment.tz.guess(); + } + Log.debug(`initial tz=${event.start.tz}`); + + // if there is a start date specified + if (event.start.tz) { + // if this is a windows timezone + if (event.start.tz.includes(" ")) { + // use the lookup table to get theIANA name as moment and date don't know MS timezones + let tz = CalendarFetcherUtils.getIanaTZFromMS(event.start.tz); + Log.debug(`corrected TZ=${tz}`); + // watch out for unregistered windows timezone names + // if we had a successful lookup + if (tz) { + // change the timezone to the IANA name + event.start.tz = tz; + // Log.debug("corrected timezone="+event.start.tz) + } + } + Log.debug(`corrected tz=${event.start.tz}`); + let current_offset = 0; // offset from TZ string or calculated + let mm = 0; // date with tz or offset + let start_offset = 0; // utc offset of created with tz + // if there is still an offset, lookup failed, use it + if (event.start.tz.startsWith("(")) { + const regex = /[+|-]\d*:\d*/; + const start_offsetString = event.start.tz.match(regex).toString().split(":"); + let start_offset = parseInt(start_offsetString[0]); + start_offset *= event.start.tz[1] === "-" ? -1 : 1; + adjustHours = start_offset; + Log.debug(`defined offset=${start_offset} hours`); + current_offset = start_offset; + event.start.tz = ""; + Log.debug(`ical offset=${current_offset} date=${date}`); + mm = moment(date); + let x = parseInt(moment(new Date()).utcOffset()); + Log.debug(`net mins=${current_offset * 60 - x}`); + + mm = mm.add(x - current_offset * 60, "minutes"); + adjustHours = (current_offset * 60 - x) / 60; + event.start = mm.toDate(); + Log.debug(`adjusted date=${event.start}`); + } else { + // get the start time in that timezone + let es = moment(event.start); + // check for start date prior to start of daylight changing date + if (es.format("YYYY") < 2007) { + es.set("year", 2013); // if so, use a closer date + } + Log.debug(`start date/time=${es.toDate()}`); + start_offset = moment.tz(es, event.start.tz).utcOffset(); + Log.debug(`start offset=${start_offset}`); + + Log.debug(`start date/time w tz =${moment.tz(moment(event.start), event.start.tz).toDate()}`); + + // get the specified date in that timezone + mm = moment.tz(moment(date), event.start.tz); + Log.debug(`event date=${mm.toDate()}`); + current_offset = mm.utcOffset(); + } + Log.debug(`event offset=${current_offset} hour=${mm.format("H")} event date=${mm.toDate()}`); + + // if the offset is greater than 0, east of london + if (current_offset !== start_offset) { + // big offset + Log.debug("offset"); + let h = parseInt(mm.format("H")); + // check if the event time is less than the offset + if (h > 0 && h < Math.abs(current_offset) / 60) { + // if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time) + // we need to fix that + //adjustHours = 24; + // Log.debug("adjusting date") + } + //-300 > -240 + //if (Math.abs(current_offset) > Math.abs(start_offset)){ + if (current_offset > start_offset) { + adjustHours -= 1; + Log.debug("adjust down 1 hour dst change"); + //} else if (Math.abs(current_offset) < Math.abs(start_offset)) { + } else if (current_offset < start_offset) { + adjustHours += 1; + Log.debug("adjust up 1 hour dst change"); + } + } + } + Log.debug(`adjustHours=${adjustHours}`); + return adjustHours; + }, + + /** + * Filter the events from ical according to the given config + * @param {object} data the calendar data from ical + * @param {object} config The configuration object + * @returns {string[]} the filtered events + */ + filterEvents: function (data, config) { + const newEvents = []; + + // limitFunction doesn't do much limiting, see comment re: the dates + // array in rrule section below as to why we need to do the filtering + // ourselves + const limitFunction = function (date, i) { + return true; + }; + + const eventDate = function (event, time) { + return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time])); + }; + + Log.debug(`There are ${Object.entries(data).length} calendar entries.`); + Object.entries(data).forEach(([key, event]) => { + Log.debug("Processing entry..."); + const now = new Date(); + const today = moment().startOf("day").toDate(); + const future = moment().startOf("day").add(config.maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat. + let past = today; + + if (config.includePastEvents) { + past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); + } + + // FIXME: Ugly fix to solve the facebook birthday issue. + // Otherwise, the recurring events only show the birthday for next year. + let isFacebookBirthday = false; + if (typeof event.uid !== "undefined") { + if (event.uid.indexOf("@facebook.com") !== -1) { + isFacebookBirthday = true; + } + } + + if (event.type === "VEVENT") { + Log.debug(`Event:\n${JSON.stringify(event)}`); + let startDate = eventDate(event, "start"); + let endDate; + + if (typeof event.end !== "undefined") { + endDate = eventDate(event, "end"); + } else if (typeof event.duration !== "undefined") { + endDate = startDate.clone().add(moment.duration(event.duration)); + } else { + if (!isFacebookBirthday) { + // make copy of start date, separate storage area + endDate = moment(startDate.format("x"), "x"); + } else { + endDate = moment(startDate).add(1, "days"); + } + } + + Log.debug(`start: ${startDate.toDate()}`); + Log.debug(`end:: ${endDate.toDate()}`); + + // Calculate the duration of the event for use with recurring events. + let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x")); + Log.debug(`duration: ${duration}`); + + // FIXME: Since the parsed json object from node-ical comes with time information + // this check could be removed (?) + if (event.start.length === 8) { + startDate = startDate.startOf("day"); + } + + const title = CalendarFetcherUtils.getTitleFromEvent(event); + Log.debug(`title: ${title}`); + + let excluded = false, + dateFilter = null; + + for (let f in config.excludedEvents) { + let filter = config.excludedEvents[f], + testTitle = title.toLowerCase(), + until = null, + useRegex = false, + regexFlags = "g"; + + if (filter instanceof Object) { + if (typeof filter.until !== "undefined") { + until = filter.until; + } + + if (typeof filter.regex !== "undefined") { + useRegex = filter.regex; + } + + // If additional advanced filtering is added in, this section + // must remain last as we overwrite the filter object with the + // filterBy string + if (filter.caseSensitive) { + filter = filter.filterBy; + testTitle = title; + } else if (useRegex) { + filter = filter.filterBy; + testTitle = title; + regexFlags += "i"; + } else { + filter = filter.filterBy.toLowerCase(); + } + } else { + filter = filter.toLowerCase(); + } + + if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) { + if (until) { + dateFilter = until; + } else { + excluded = true; + } + break; + } + } + + if (excluded) { + return; + } + + const location = event.location || false; + const geo = event.geo || false; + const description = event.description || false; + + if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) { + const rule = event.rrule; + let addedEvents = 0; + + const pastMoment = moment(past); + const futureMoment = moment(future); + + // can cause problems with e.g. birthdays before 1900 + if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) { + rule.origOptions.dtstart.setYear(1900); + rule.options.dtstart.setYear(1900); + } + + // For recurring events, get the set of start dates that fall within the range + // of dates we're looking for. + // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time + let pastLocal = 0; + let futureLocal = 0; + if (CalendarFetcherUtils.isFullDayEvent(event)) { + Log.debug("fullday"); + // if full day event, only use the date part of the ranges + pastLocal = pastMoment.toDate(); + futureLocal = futureMoment.toDate(); + + Log.debug(`pastLocal: ${pastLocal}`); + Log.debug(`futureLocal: ${futureLocal}`); + } else { + // if we want past events + if (config.includePastEvents) { + // use the calculated past time for the between from + pastLocal = pastMoment.toDate(); + } else { + // otherwise use NOW.. cause we shouldn't use any before now + pastLocal = moment().toDate(); //now + } + futureLocal = futureMoment.toDate(); // future + } + Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`); + const dates = rule.between(pastLocal, futureLocal, true, limitFunction); + Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`); + // The "dates" array contains the set of dates within our desired date range range that are valid + // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that + // had its date changed from outside the range to inside the range. For the time being, + // we'll handle this by adding *all* recurrence entries into the set of dates that we check, + // because the logic below will filter out any recurrences that don't actually belong within + // our display range. + // Would be great if there was a better way to handle this. + Log.debug(`event.recurrences: ${event.recurrences}`); + if (event.recurrences !== undefined) { + for (let r in event.recurrences) { + // Only add dates that weren't already in the range we added from the rrule so that + // we don"t double-add those events. + if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) { + dates.push(new Date(r)); + } + } + } + // Loop through the set of date entries to see which recurrences should be added to our event list. + for (let d in dates) { + let date = dates[d]; + // Remove the time information of each date by using its substring, using the following method: + // .toISOString().substring(0,10). + // since the date is given as ISOString with YYYY-MM-DDTHH:MM:SS.SSSZ + // (see https://momentjs.com/docs/#/displaying/as-iso-string/). + const dateKey = date.toISOString().substring(0, 10); + let curEvent = event; + let showRecurrence = true; + + // Get the offset of today where we are processing + // This will be the correction, we need to apply. + let nowOffset = new Date().getTimezoneOffset(); + // For full day events, the time might be off from RRULE/Luxon problem + // Get time zone offset of the rule calculated event + let dateoffset = date.getTimezoneOffset(); + + // Reduce the time by the following offset. + Log.debug(` recurring date is ${date} offset is ${dateoffset}`); + + let dh = moment(date).format("HH"); + Log.debug(` recurring date is ${date} offset is ${dateoffset / 60} Hour is ${dh}`); + + if (CalendarFetcherUtils.isFullDayEvent(event)) { + Log.debug("Fullday"); + // If the offset is negative (east of GMT), where the problem is + if (dateoffset < 0) { + if (dh < Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + // reduce the time by the offset + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // Apply the correction to the date/time to get it UTC relative + date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date1 fulldate is ${date}`); + } + } else { + // if the timezones are the same, correct date if needed + //if (event.start.tz === moment.tz.guess()) { + // if the date hour is less than the offset + if (24 - dh <= Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // apply the correction to the date/time back to right day + date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date2 fulldate is ${date}`); + } + //} + } + } else { + // not full day, but luxon can still screw up the date on the rule processing + // we need to correct the date to get back to the right event for + if (dateoffset < 0) { + // if the date hour is less than the offset + if (dh <= Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // Reduce the time by t: + // Apply the correction to the date/time to get it UTC relative + date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date1 is ${date}`); + } + } else { + // if the timezones are the same, correct date if needed + //if (event.start.tz === moment.tz.guess()) { + // if the date hour is less than the offset + if (24 - dh <= Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // apply the correction to the date/time back to right day + date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date2 is ${date}`); + } + //} + } + } + startDate = moment(date); + Log.debug(`Corrected startDate: ${startDate.toDate()}`); + + let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date); + + // For each date that we're checking, it's possible that there is a recurrence override for that one day. + if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) { + // We found an override, so for this recurrence, use a potentially different title, start date, and duration. + curEvent = curEvent.recurrences[dateKey]; + startDate = moment(curEvent.start); + duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x")); + } + // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. + else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) { + // This date is an exception date, which means we should skip it in the recurrence pattern. + showRecurrence = false; + } + Log.debug(`duration: ${duration}`); + + endDate = moment(parseInt(startDate.format("x")) + duration, "x"); + if (startDate.format("x") === endDate.format("x")) { + endDate = endDate.endOf("day"); + } + + const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent); + + // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add + // it to the event list. + if (endDate.isBefore(past) || startDate.isAfter(future)) { + showRecurrence = false; + } + + if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + showRecurrence = false; + } + + if (showRecurrence === true) { + Log.debug(`saving event: ${description}`); + addedEvents++; + newEvents.push({ + title: recurrenceTitle, + startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), + endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), + recurringEvent: true, + class: event.class, + firstYear: event.start.getFullYear(), + location: location, + geo: geo, + description: description + }); + } + } + // End recurring event parsing. + } else { + // Single event. + const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); + // Log.debug("full day event") + + // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) + if (fullDayEvent && startDate.format("x") === endDate.format("x")) { + endDate = endDate.endOf("day"); + } + + if (config.includePastEvents) { + // Past event is too far in the past, so skip. + if (endDate < past) { + return; + } + } else { + // It's not a fullday event, and it is in the past, so skip. + if (!fullDayEvent && endDate < new Date()) { + return; + } + + // It's a fullday event, and it is before today, So skip. + if (fullDayEvent && endDate <= today) { + return; + } + } + + // It exceeds the maximumNumberOfDays limit, so skip. + if (startDate > future) { + return; + } + + if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + return; + } + + // get correction for date saving and dst change between now and then + let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startDate.toDate()); + // Every thing is good. Add it to the list. + newEvents.push({ + title: title, + startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), + endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + fullDayEvent: fullDayEvent, + class: event.class, + location: location, + geo: geo, + description: description + }); + } + } + }); + + newEvents.sort(function (a, b) { + return a.startDate - b.startDate; + }); + + return newEvents; + }, + + /** + * Lookup iana tz from windows + * @param {string} msTZName the timezone name to lookup + * @returns {string|null} the iana name or null of none is found + */ + getIanaTZFromMS: function (msTZName) { + // Get hash entry + const he = zoneTable[msTZName]; + // If found return iana name, else null + return he ? he.iana[0] : null; + }, + + /** + * Gets the title from the event. + * @param {object} event The event object to check. + * @returns {string} The title of the event, or "Event" if no title is found. + */ + getTitleFromEvent: function (event) { + let title = "Event"; + if (event.summary) { + title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary; + } else if (event.description) { + title = event.description; + } + + return title; + }, + + /** + * Checks if an event is a fullday event. + * @param {object} event The event object to check. + * @returns {boolean} True if the event is a fullday event, false otherwise + */ + isFullDayEvent: function (event) { + if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") { + return true; + } + + const start = event.start || 0; + const startDate = new Date(start); + const end = event.end || 0; + if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) { + // Is 24 hours, and starts on the middle of the night. + return true; + } + + return false; + }, + + /** + * Determines if the user defined time filter should apply + * @param {Date} now Date object using previously created object for consistency + * @param {Moment} endDate Moment object representing the event end date + * @param {string} filter The time to subtract from the end date to determine if an event should be shown + * @returns {boolean} True if the event should be filtered out, false otherwise + */ + timeFilterApplies: function (now, endDate, filter) { + if (filter) { + const until = filter.split(" "), + value = parseInt(until[0]), + increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js + filterUntil = moment(endDate.format()).subtract(value, increment); + + return now < filterUntil.format("x"); + } + + return false; + }, + + /** + * Determines if the user defined title filter should apply + * @param {string} title the title of the event + * @param {string} filter the string to look for, can be a regex also + * @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string + * @param {string} regexFlags flags that should be applied to the regex + * @returns {boolean} True if the title should be filtered out, false otherwise + */ + titleFilterApplies: function (title, filter, useRegex, regexFlags) { + if (useRegex) { + let regexFilter = filter; + // Assume if leading slash, there is also trailing slash + if (filter[0] === "/") { + // Strip leading and trailing slashes + regexFilter = filter.substr(1).slice(0, -1); + } + return new RegExp(regexFilter, regexFlags).test(title); + } else { + return title.includes(filter); + } + } +}; + +if (typeof module !== "undefined") { + module.exports = CalendarFetcherUtils; +} diff --git a/mm/mounts/modules/default/calendar/calendarutils.js b/mm/mounts/modules/default/calendar/calendarutils.js new file mode 100644 index 0000000..e953b63 --- /dev/null +++ b/mm/mounts/modules/default/calendar/calendarutils.js @@ -0,0 +1,117 @@ +/* MagicMirror² + * Calendar Util Methods + * + * By Rejas + * MIT Licensed. + */ +const CalendarUtils = { + /** + * Capitalize the first letter of a string + * @param {string} string The string to capitalize + * @returns {string} The capitalized string + */ + capFirst: function (string) { + return string.charAt(0).toUpperCase() + string.slice(1); + }, + + /** + * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the + * corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input) + * it will a localeSpecification object with the system locale time format. + * @param {number} timeFormat Specifies either 12 or 24-hour time format + * @returns {moment.LocaleSpecification} formatted time + */ + getLocaleSpecification: function (timeFormat) { + switch (timeFormat) { + case 12: { + return { longDateFormat: { LT: "h:mm A" } }; + } + case 24: { + return { longDateFormat: { LT: "HH:mm" } }; + } + default: { + return { longDateFormat: { LT: moment.localeData().longDateFormat("LT") } }; + } + } + }, + + /** + * Shortens a string if it's longer than maxLength and add an ellipsis to the end + * @param {string} string Text string to shorten + * @param {number} maxLength The max length of the string + * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength + * @param {number} maxTitleLines The max number of vertical lines before cutting event title + * @returns {string} The shortened string + */ + shorten: function (string, maxLength, wrapEvents, maxTitleLines) { + if (typeof string !== "string") { + return ""; + } + + if (wrapEvents === true) { + const words = string.split(" "); + let temp = ""; + let currentLine = ""; + let line = 0; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) { + // max - 1 to account for a space + currentLine += `${word} `; + } else { + line++; + if (line > maxTitleLines - 1) { + if (i < words.length) { + currentLine += "…"; + } + break; + } + + if (currentLine.length > 0) { + temp += `${currentLine}
${word} `; + } else { + temp += `${word}
`; + } + currentLine = ""; + } + } + + return (temp + currentLine).trim(); + } else { + if (maxLength && typeof maxLength === "number" && string.length > maxLength) { + return `${string.trim().slice(0, maxLength)}…`; + } else { + return string.trim(); + } + } + }, + + /** + * Transforms the title of an event for usage. + * Replaces parts of the text as defined in config.titleReplace. + * Shortens title based on config.maxTitleLength and config.wrapEvents + * @param {string} title The title to transform. + * @param {object} titleReplace Pairs of strings to be replaced in the title + * @returns {string} The transformed title. + */ + titleTransform: function (title, titleReplace) { + let transformedTitle = title; + for (let needle in titleReplace) { + const replacement = titleReplace[needle]; + + const regParts = needle.match(/^\/(.+)\/([gim]*)$/); + if (regParts) { + // the parsed pattern is a regexp. + needle = new RegExp(regParts[1], regParts[2]); + } + + transformedTitle = transformedTitle.replace(needle, replacement); + } + return transformedTitle; + } +}; + +if (typeof module !== "undefined") { + module.exports = CalendarUtils; +} diff --git a/mm/mounts/modules/default/calendar/debug.js b/mm/mounts/modules/default/calendar/debug.js new file mode 100644 index 0000000..5e19e13 --- /dev/null +++ b/mm/mounts/modules/default/calendar/debug.js @@ -0,0 +1,43 @@ +/* CalendarFetcher Tester + * use this script with `node debug.js` to test the fetcher without the need + * of starting the MagicMirror² core. Adjust the values below to your desire. + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +// Alias modules mentioned in package.js under _moduleAliases. +require("module-alias/register"); + +const CalendarFetcher = require("./calendarfetcher"); + +const url = "https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics"; // Standard test URL +//const url = "https://www.googleapis.com/calendar/v3/calendars/primary/events/"; // URL for Bearer auth (must be configured in Google OAuth2 first) +const fetchInterval = 60 * 60 * 1000; +const maximumEntries = 10; +const maximumNumberOfDays = 365; +const user = "magicmirror"; +const pass = "MyStrongPass"; +const auth = { + user: user, + pass: pass +}; + +console.log("Create fetcher ..."); + +const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth); + +fetcher.onReceive(function (fetcher) { + console.log(fetcher.events()); + console.log("------------------------------------------------------------"); + process.exit(0); +}); + +fetcher.onError(function (fetcher, error) { + console.log("Fetcher error:"); + console.log(error); + process.exit(1); +}); + +fetcher.startFetch(); + +console.log("Create fetcher done! "); diff --git a/mm/mounts/modules/default/calendar/node_helper.js b/mm/mounts/modules/default/calendar/node_helper.js new file mode 100644 index 0000000..05d4d45 --- /dev/null +++ b/mm/mounts/modules/default/calendar/node_helper.js @@ -0,0 +1,95 @@ +/* MagicMirror² + * Node Helper: Calendar + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +const NodeHelper = require("node_helper"); +const Log = require("logger"); +const CalendarFetcher = require("./calendarfetcher"); + +module.exports = NodeHelper.create({ + // Override start method. + start: function () { + Log.log(`Starting node helper for: ${this.name}`); + this.fetchers = []; + }, + + // Override socketNotificationReceived method. + socketNotificationReceived: function (notification, payload) { + if (notification === "ADD_CALENDAR") { + this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id); + } else if (notification === "FETCH_CALENDAR") { + const key = payload.id + payload.url; + if (typeof this.fetchers[key] === "undefined") { + Log.error("Calendar Error. No fetcher exists with key: ", key); + this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" }); + return; + } + this.fetchers[key].startFetch(); + } + }, + + /** + * Creates a fetcher for a new url if it doesn't exist yet. + * Otherwise it reuses the existing one. + * @param {string} url The url of the calendar + * @param {number} fetchInterval How often does the calendar needs to be fetched in ms + * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. + * @param {number} maximumEntries The maximum number of events fetched. + * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. + * @param {object} auth The object containing options for authentication against the calendar. + * @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts + * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. + * @param {string} identifier ID of the module + */ + createFetcher: function (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) { + try { + new URL(url); + } catch (error) { + Log.error("Calendar Error. Malformed calendar url: ", url, error); + this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" }); + return; + } + + let fetcher; + if (typeof this.fetchers[identifier + url] === "undefined") { + Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchInterval}`); + fetcher = new CalendarFetcher(url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert); + + fetcher.onReceive((fetcher) => { + this.broadcastEvents(fetcher, identifier); + }); + + fetcher.onError((fetcher, error) => { + Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error); + let error_type = NodeHelper.checkFetchError(error); + this.sendSocketNotification("CALENDAR_ERROR", { + id: identifier, + error_type + }); + }); + + this.fetchers[identifier + url] = fetcher; + } else { + Log.log(`Use existing calendarfetcher for url: ${url}`); + fetcher = this.fetchers[identifier + url]; + fetcher.broadcastEvents(); + } + + fetcher.startFetch(); + }, + + /** + * + * @param {object} fetcher the fetcher associated with the calendar + * @param {string} identifier the identifier of the calendar + */ + broadcastEvents: function (fetcher, identifier) { + this.sendSocketNotification("CALENDAR_EVENTS", { + id: identifier, + url: fetcher.url(), + events: fetcher.events() + }); + } +}); diff --git a/mm/mounts/modules/default/calendar/windowsZones.json b/mm/mounts/modules/default/calendar/windowsZones.json new file mode 100644 index 0000000..cad82bb --- /dev/null +++ b/mm/mounts/modules/default/calendar/windowsZones.json @@ -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] } +} diff --git a/mm/mounts/modules/default/clock/README.md b/mm/mounts/modules/default/clock/README.md new file mode 100644 index 0000000..16703eb --- /dev/null +++ b/mm/mounts/modules/default/clock/README.md @@ -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). diff --git a/mm/mounts/modules/default/clock/clock.js b/mm/mounts/modules/default/clock/clock.js new file mode 100644 index 0000000..c063d4d --- /dev/null +++ b/mm/mounts/modules/default/clock/clock.js @@ -0,0 +1,306 @@ +/* global SunCalc, formatTime */ + +/* MagicMirror² + * Module: Clock + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +Module.register("clock", { + // Module config defaults. + defaults: { + displayType: "digital", // options: digital, analog, both + + timeFormat: config.timeFormat, + timezone: null, + + displaySeconds: true, + showPeriod: true, + showPeriodUpper: false, + clockBold: false, + showDate: true, + showTime: true, + showWeek: false, + dateFormat: "dddd, LL", + sendNotifications: false, + + /* specific to the analog clock */ + analogSize: "200px", + analogFace: "simple", // options: 'none', 'simple', 'face-###' (where ### is 001 to 012 inclusive) + analogPlacement: "bottom", // options: 'top', 'bottom', 'left', 'right' + analogShowDate: "top", // OBSOLETE, can be replaced with analogPlacement and showTime, options: false, 'top', or 'bottom' + secondsColor: "#888888", + + showSunTimes: false, + showMoonTimes: false, + lat: 47.630539, + lon: -122.344147 + }, + // Define required scripts. + getScripts: function () { + return ["moment.js", "moment-timezone.js", "suncalc.js"]; + }, + // Define styles. + getStyles: function () { + return ["clock_styles.css"]; + }, + // Define start sequence. + start: function () { + Log.info(`Starting module: ${this.name}`); + + // Schedule update interval. + this.second = moment().second(); + this.minute = moment().minute(); + + // Calculate how many ms should pass until next update depending on if seconds is displayed or not + const delayCalculator = (reducedSeconds) => { + const EXTRA_DELAY = 50; // Deliberate imperceptible delay to prevent off-by-one timekeeping errors + + if (this.config.displaySeconds) { + return 1000 - moment().milliseconds() + EXTRA_DELAY; + } else { + return (60 - reducedSeconds) * 1000 - moment().milliseconds() + EXTRA_DELAY; + } + }; + + // A recursive timeout function instead of interval to avoid drifting + const notificationTimer = () => { + this.updateDom(); + + if (this.config.sendNotifications) { + // If seconds is displayed CLOCK_SECOND-notification should be sent (but not when CLOCK_MINUTE-notification is sent) + if (this.config.displaySeconds) { + this.second = moment().second(); + if (this.second !== 0) { + this.sendNotification("CLOCK_SECOND", this.second); + setTimeout(notificationTimer, delayCalculator(0)); + return; + } + } + + // If minute changed or seconds isn't displayed send CLOCK_MINUTE-notification + this.minute = moment().minute(); + this.sendNotification("CLOCK_MINUTE", this.minute); + } + + setTimeout(notificationTimer, delayCalculator(0)); + }; + + // Set the initial timeout with the amount of seconds elapsed as + // reducedSeconds, so it will trigger when the minute changes + setTimeout(notificationTimer, delayCalculator(this.second)); + + // Set locale. + moment.locale(config.language); + }, + // Override dom generator. + getDom: function () { + const wrapper = document.createElement("div"); + wrapper.classList.add("clock-grid"); + + /************************************ + * Create wrappers for analog and digital clock + */ + const analogWrapper = document.createElement("div"); + analogWrapper.className = "clock-circle"; + const digitalWrapper = document.createElement("div"); + digitalWrapper.className = "digital"; + digitalWrapper.style.gridArea = "center"; + + /************************************ + * Create wrappers for DIGITAL clock + */ + const dateWrapper = document.createElement("div"); + const timeWrapper = document.createElement("div"); + const secondsWrapper = document.createElement("sup"); + const periodWrapper = document.createElement("span"); + const sunWrapper = document.createElement("div"); + const moonWrapper = document.createElement("div"); + const weekWrapper = document.createElement("div"); + + // Style Wrappers + dateWrapper.className = "date normal medium"; + timeWrapper.className = "time bright large light"; + secondsWrapper.className = "seconds dimmed"; + sunWrapper.className = "sun dimmed small"; + moonWrapper.className = "moon dimmed small"; + weekWrapper.className = "week dimmed medium"; + + // Set content of wrappers. + // The moment().format("h") method has a bug on the Raspberry Pi. + // So we need to generate the timestring manually. + // See issue: https://github.com/MichMich/MagicMirror/issues/181 + let timeString; + const now = moment(); + if (this.config.timezone) { + now.tz(this.config.timezone); + } + + let hourSymbol = "HH"; + if (this.config.timeFormat !== 24) { + hourSymbol = "h"; + } + + if (this.config.clockBold) { + timeString = now.format(`${hourSymbol}[]mm[]`); + } else { + timeString = now.format(`${hourSymbol}:mm`); + } + + if (this.config.showDate) { + dateWrapper.innerHTML = now.format(this.config.dateFormat); + digitalWrapper.appendChild(dateWrapper); + } + + if (this.config.displayType !== "analog" && this.config.showTime) { + timeWrapper.innerHTML = timeString; + secondsWrapper.innerHTML = now.format("ss"); + if (this.config.showPeriodUpper) { + periodWrapper.innerHTML = now.format("A"); + } else { + periodWrapper.innerHTML = now.format("a"); + } + if (this.config.displaySeconds) { + timeWrapper.appendChild(secondsWrapper); + } + if (this.config.showPeriod && this.config.timeFormat !== 24) { + timeWrapper.appendChild(periodWrapper); + } + digitalWrapper.appendChild(timeWrapper); + } + + /**************************************************************** + * Create wrappers for Sun Times, only if specified in config + */ + if (this.config.showSunTimes) { + const sunTimes = SunCalc.getTimes(now, this.config.lat, this.config.lon); + const isVisible = now.isBetween(sunTimes.sunrise, sunTimes.sunset); + let nextEvent; + if (now.isBefore(sunTimes.sunrise)) { + nextEvent = sunTimes.sunrise; + } else if (now.isBefore(sunTimes.sunset)) { + nextEvent = sunTimes.sunset; + } else { + const tomorrowSunTimes = SunCalc.getTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon); + nextEvent = tomorrowSunTimes.sunrise; + } + const untilNextEvent = moment.duration(moment(nextEvent).diff(now)); + const untilNextEventString = `${untilNextEvent.hours()}h ${untilNextEvent.minutes()}m`; + sunWrapper.innerHTML = + ` ${untilNextEventString}` + + ` ${formatTime(this.config, sunTimes.sunrise)}` + + ` ${formatTime(this.config, sunTimes.sunset)}`; + digitalWrapper.appendChild(sunWrapper); + } + + /**************************************************************** + * Create wrappers for Moon Times, only if specified in config + */ + if (this.config.showMoonTimes) { + const moonIllumination = SunCalc.getMoonIllumination(now.toDate()); + const moonTimes = SunCalc.getMoonTimes(now, this.config.lat, this.config.lon); + const moonRise = moonTimes.rise; + let moonSet; + if (moment(moonTimes.set).isAfter(moonTimes.rise)) { + moonSet = moonTimes.set; + } else { + const nextMoonTimes = SunCalc.getMoonTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon); + moonSet = nextMoonTimes.set; + } + const isVisible = now.isBetween(moonRise, moonSet) || moonTimes.alwaysUp === true; + const illuminatedFractionString = `${Math.round(moonIllumination.fraction * 100)}%`; + moonWrapper.innerHTML = + ` ${illuminatedFractionString}` + + ` ${moonRise ? formatTime(this.config, moonRise) : "..."}` + + ` ${moonSet ? formatTime(this.config, moonSet) : "..."}`; + digitalWrapper.appendChild(moonWrapper); + } + + if (this.config.showWeek) { + weekWrapper.innerHTML = this.translate("WEEK", { weekNumber: now.week() }); + digitalWrapper.appendChild(weekWrapper); + } + + /**************************************************************** + * Create wrappers for ANALOG clock, only if specified in config + */ + if (this.config.displayType !== "digital") { + // If it isn't 'digital', then an 'analog' clock was also requested + + // Calculate the degree offset for each hand of the clock + if (this.config.timezone) { + now.tz(this.config.timezone); + } + const second = now.seconds() * 6, + minute = now.minute() * 6 + second / 60, + hour = ((now.hours() % 12) / 12) * 360 + 90 + minute / 12; + + // Create wrappers + analogWrapper.style.width = this.config.analogSize; + analogWrapper.style.height = this.config.analogSize; + + if (this.config.analogFace !== "" && this.config.analogFace !== "simple" && this.config.analogFace !== "none") { + analogWrapper.style.background = `url(${this.data.path}faces/${this.config.analogFace}.svg)`; + analogWrapper.style.backgroundSize = "100%"; + + // The following line solves issue: https://github.com/MichMich/MagicMirror/issues/611 + // analogWrapper.style.border = "1px solid black"; + analogWrapper.style.border = "rgba(0, 0, 0, 0.1)"; //Updated fix for Issue 611 where non-black backgrounds are used + } else if (this.config.analogFace !== "none") { + analogWrapper.style.border = "2px solid white"; + } + const clockFace = document.createElement("div"); + clockFace.className = "clock-face"; + + const clockHour = document.createElement("div"); + clockHour.id = "clock-hour"; + clockHour.style.transform = `rotate(${hour}deg)`; + clockHour.className = "clock-hour"; + const clockMinute = document.createElement("div"); + clockMinute.id = "clock-minute"; + clockMinute.style.transform = `rotate(${minute}deg)`; + clockMinute.className = "clock-minute"; + + // Combine analog wrappers + clockFace.appendChild(clockHour); + clockFace.appendChild(clockMinute); + + if (this.config.displaySeconds) { + const clockSecond = document.createElement("div"); + clockSecond.id = "clock-second"; + clockSecond.style.transform = `rotate(${second}deg)`; + clockSecond.className = "clock-second"; + clockSecond.style.backgroundColor = this.config.secondsColor; + clockFace.appendChild(clockSecond); + } + analogWrapper.appendChild(clockFace); + } + + /******************************************* + * Update placement, respect old analogShowDate even if it's not needed anymore + */ + if (this.config.displayType === "analog") { + // Display only an analog clock + if (this.config.showDate) { + // Add date to the analog clock + dateWrapper.innerHTML = now.format(this.config.dateFormat); + wrapper.appendChild(dateWrapper); + } + if (this.config.analogShowDate === "bottom") { + wrapper.classList.add("clock-grid-bottom"); + } else if (this.config.analogShowDate === "top") { + wrapper.classList.add("clock-grid-top"); + } + wrapper.appendChild(analogWrapper); + } else if (this.config.displayType === "digital") { + wrapper.appendChild(digitalWrapper); + } else if (this.config.displayType === "both") { + wrapper.classList.add(`clock-grid-${this.config.analogPlacement}`); + wrapper.appendChild(analogWrapper); + wrapper.appendChild(digitalWrapper); + } + + // Return the wrapper to the dom. + return wrapper; + } +}); diff --git a/mm/mounts/modules/default/clock/clock_styles.css b/mm/mounts/modules/default/clock/clock_styles.css new file mode 100644 index 0000000..e938dd2 --- /dev/null +++ b/mm/mounts/modules/default/clock/clock_styles.css @@ -0,0 +1,93 @@ +.clock-grid { + display: inline-flex; + gap: 15px; +} + +.clock-grid-left { + flex-direction: row; +} + +.clock-grid-right { + flex-direction: row-reverse; +} + +.clock-grid-top { + flex-direction: column; +} + +.clock-grid-bottom { + flex-direction: column-reverse; +} + +.clock-circle { + place-self: center; + position: relative; + border-radius: 50%; + background-size: 100%; +} + +.clock-face { + width: 100%; + height: 100%; +} + +.clock-face::after { + position: absolute; + top: 50%; + left: 50%; + width: 6px; + height: 6px; + margin: -3px 0 0 -3px; + background: var(--color-text-bright); + border-radius: 3px; + content: ""; + display: block; +} + +.clock-hour { + width: 0; + height: 0; + position: absolute; + top: 50%; + left: 50%; + margin: -2px 0 -2px -25%; /* numbers must match negative length & thickness */ + padding: 2px 0 2px 25%; /* indicator length & thickness */ + background: var(--color-text-bright); + transform-origin: 100% 50%; + border-radius: 3px 0 0 3px; +} + +.clock-minute { + width: 0; + height: 0; + position: absolute; + top: 50%; + left: 50%; + margin: -35% -2px 0; /* numbers must match negative length & thickness */ + padding: 35% 2px 0; /* indicator length & thickness */ + background: var(--color-text-bright); + transform-origin: 50% 100%; + border-radius: 3px 0 0 3px; +} + +.clock-second { + width: 0; + height: 0; + position: absolute; + top: 50%; + left: 50%; + margin: -38% -1px 0 0; /* numbers must match negative length & thickness */ + padding: 38% 1px 0 0; /* indicator length & thickness */ + background: var(--color-text); + transform-origin: 50% 100%; +} + +.module.clock .sun, +.module.clock .moon { + display: flex; +} + +.module.clock .sun > *, +.module.clock .moon > * { + flex: 1; +} diff --git a/mm/mounts/modules/default/clock/faces/face-001.svg b/mm/mounts/modules/default/clock/faces/face-001.svg new file mode 100644 index 0000000..abd08ce --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-001.svg @@ -0,0 +1 @@ +face-001 diff --git a/mm/mounts/modules/default/clock/faces/face-002.svg b/mm/mounts/modules/default/clock/faces/face-002.svg new file mode 100644 index 0000000..1ec3104 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-002.svg @@ -0,0 +1 @@ +face-002 diff --git a/mm/mounts/modules/default/clock/faces/face-003.svg b/mm/mounts/modules/default/clock/faces/face-003.svg new file mode 100644 index 0000000..7cfeeba --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-003.svg @@ -0,0 +1 @@ +face-003 diff --git a/mm/mounts/modules/default/clock/faces/face-004.svg b/mm/mounts/modules/default/clock/faces/face-004.svg new file mode 100644 index 0000000..bc97588 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-004.svg @@ -0,0 +1 @@ +face-004 diff --git a/mm/mounts/modules/default/clock/faces/face-005.svg b/mm/mounts/modules/default/clock/faces/face-005.svg new file mode 100644 index 0000000..0bc1b43 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-005.svg @@ -0,0 +1 @@ +faces diff --git a/mm/mounts/modules/default/clock/faces/face-006.svg b/mm/mounts/modules/default/clock/faces/face-006.svg new file mode 100644 index 0000000..63d1c93 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-006.svg @@ -0,0 +1 @@ +face-006 diff --git a/mm/mounts/modules/default/clock/faces/face-007.svg b/mm/mounts/modules/default/clock/faces/face-007.svg new file mode 100644 index 0000000..e557f55 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-007.svg @@ -0,0 +1 @@ +face-007 diff --git a/mm/mounts/modules/default/clock/faces/face-008.svg b/mm/mounts/modules/default/clock/faces/face-008.svg new file mode 100644 index 0000000..6fadb39 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-008.svg @@ -0,0 +1 @@ +face-008 diff --git a/mm/mounts/modules/default/clock/faces/face-009.svg b/mm/mounts/modules/default/clock/faces/face-009.svg new file mode 100644 index 0000000..bd207e0 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-009.svg @@ -0,0 +1 @@ +face-009 diff --git a/mm/mounts/modules/default/clock/faces/face-010.svg b/mm/mounts/modules/default/clock/faces/face-010.svg new file mode 100644 index 0000000..8c5e584 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-010.svg @@ -0,0 +1 @@ +face-010 diff --git a/mm/mounts/modules/default/clock/faces/face-011.svg b/mm/mounts/modules/default/clock/faces/face-011.svg new file mode 100644 index 0000000..9886fed --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-011.svg @@ -0,0 +1 @@ +face-011 diff --git a/mm/mounts/modules/default/clock/faces/face-012.svg b/mm/mounts/modules/default/clock/faces/face-012.svg new file mode 100644 index 0000000..cfd8069 --- /dev/null +++ b/mm/mounts/modules/default/clock/faces/face-012.svg @@ -0,0 +1 @@ +face-012 diff --git a/mm/mounts/modules/default/compliments/README.md b/mm/mounts/modules/default/compliments/README.md new file mode 100644 index 0000000..57e105f --- /dev/null +++ b/mm/mounts/modules/default/compliments/README.md @@ -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). diff --git a/mm/mounts/modules/default/compliments/compliments.js b/mm/mounts/modules/default/compliments/compliments.js new file mode 100644 index 0000000..a905581 --- /dev/null +++ b/mm/mounts/modules/default/compliments/compliments.js @@ -0,0 +1,181 @@ +/* MagicMirror² + * Module: Compliments + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +Module.register("compliments", { + // Module config defaults. + defaults: { + compliments: { + anytime: ["Hey there sexy!"], + morning: ["Good morning, handsome!", "Enjoy your day!", "How was your sleep?"], + afternoon: ["Hello, beauty!", "You look sexy!", "Looking good today!"], + evening: ["Wow, you look hot!", "You look nice!", "Hi, sexy!"], + "....-01-01": ["Happy new year!"] + }, + updateInterval: 30000, + remoteFile: null, + fadeSpeed: 4000, + morningStartTime: 3, + morningEndTime: 12, + afternoonStartTime: 12, + afternoonEndTime: 17, + random: true + }, + lastIndexUsed: -1, + // Set currentweather from module + currentWeatherType: "", + + // Define required scripts. + getScripts: function () { + return ["moment.js"]; + }, + + // Define start sequence. + start: async function () { + Log.info(`Starting module: ${this.name}`); + + this.lastComplimentIndex = -1; + + if (this.config.remoteFile !== null) { + const response = await this.loadComplimentFile(); + this.config.compliments = JSON.parse(response); + this.updateDom(); + } + + // Schedule update timer. + setInterval(() => { + this.updateDom(this.config.fadeSpeed); + }, this.config.updateInterval); + }, + + /** + * Generate a random index for a list of compliments. + * @param {string[]} compliments Array with compliments. + * @returns {number} a random index of given array + */ + randomIndex: function (compliments) { + if (compliments.length === 1) { + return 0; + } + + const generate = function () { + return Math.floor(Math.random() * compliments.length); + }; + + let complimentIndex = generate(); + + while (complimentIndex === this.lastComplimentIndex) { + complimentIndex = generate(); + } + + this.lastComplimentIndex = complimentIndex; + + return complimentIndex; + }, + + /** + * Retrieve an array of compliments for the time of the day. + * @returns {string[]} array with compliments for the time of the day. + */ + complimentArray: function () { + const hour = moment().hour(); + const date = moment().format("YYYY-MM-DD"); + let compliments = []; + + // Add time of day compliments + if (hour >= this.config.morningStartTime && hour < this.config.morningEndTime && this.config.compliments.hasOwnProperty("morning")) { + compliments = [...this.config.compliments.morning]; + } else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime && this.config.compliments.hasOwnProperty("afternoon")) { + compliments = [...this.config.compliments.afternoon]; + } else if (this.config.compliments.hasOwnProperty("evening")) { + compliments = [...this.config.compliments.evening]; + } + + // Add compliments based on weather + if (this.currentWeatherType in this.config.compliments) { + Array.prototype.push.apply(compliments, this.config.compliments[this.currentWeatherType]); + } + + // Add compliments for anytime + Array.prototype.push.apply(compliments, this.config.compliments.anytime); + + // Add compliments for special days + for (let entry in this.config.compliments) { + if (new RegExp(entry).test(date)) { + Array.prototype.push.apply(compliments, this.config.compliments[entry]); + } + } + + return compliments; + }, + + /** + * Retrieve a file from the local filesystem + * @returns {Promise} Resolved when the file is loaded + */ + loadComplimentFile: async function () { + const isRemote = this.config.remoteFile.indexOf("http://") === 0 || this.config.remoteFile.indexOf("https://") === 0, + url = isRemote ? this.config.remoteFile : this.file(this.config.remoteFile); + const response = await fetch(url); + return await response.text(); + }, + + /** + * Retrieve a random compliment. + * @returns {string} a compliment + */ + getRandomCompliment: function () { + // get the current time of day compliments list + const compliments = this.complimentArray(); + // variable for index to next message to display + let index; + // are we randomizing + if (this.config.random) { + // yes + index = this.randomIndex(compliments); + } else { + // no, sequential + // if doing sequential, don't fall off the end + index = this.lastIndexUsed >= compliments.length - 1 ? 0 : ++this.lastIndexUsed; + } + + return compliments[index] || ""; + }, + + // Override dom generator. + getDom: function () { + const wrapper = document.createElement("div"); + wrapper.className = this.config.classes ? this.config.classes : "thin xlarge bright pre-line"; + // get the compliment text + const complimentText = this.getRandomCompliment(); + // split it into parts on newline text + const parts = complimentText.split("\n"); + // create a span to hold the compliment + const compliment = document.createElement("span"); + // process all the parts of the compliment text + for (const part of parts) { + if (part !== "") { + // create a text element for each part + compliment.appendChild(document.createTextNode(part)); + // add a break + compliment.appendChild(document.createElement("BR")); + } + } + // only add compliment to wrapper if there is actual text in there + if (compliment.children.length > 0) { + // remove the last break + compliment.lastElementChild.remove(); + wrapper.appendChild(compliment); + } + return wrapper; + }, + + // Override notification handler. + notificationReceived: function (notification, payload, sender) { + if (notification === "CURRENTWEATHER_TYPE") { + this.currentWeatherType = payload.type; + } + } +}); diff --git a/mm/mounts/modules/default/defaultmodules.js b/mm/mounts/modules/default/defaultmodules.js new file mode 100644 index 0000000..c74e94a --- /dev/null +++ b/mm/mounts/modules/default/defaultmodules.js @@ -0,0 +1,12 @@ +/* MagicMirror² Default Modules List + * Modules listed below can be loaded without the 'default/' prefix. Omitting the default folder name. + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +const defaultModules = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather"]; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = defaultModules; +} diff --git a/mm/mounts/modules/default/helloworld/README.md b/mm/mounts/modules/default/helloworld/README.md new file mode 100644 index 0000000..065d5f9 --- /dev/null +++ b/mm/mounts/modules/default/helloworld/README.md @@ -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). diff --git a/mm/mounts/modules/default/helloworld/helloworld.js b/mm/mounts/modules/default/helloworld/helloworld.js new file mode 100644 index 0000000..53357d0 --- /dev/null +++ b/mm/mounts/modules/default/helloworld/helloworld.js @@ -0,0 +1,20 @@ +/* MagicMirror² + * Module: HelloWorld + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +Module.register("helloworld", { + // Default module config. + defaults: { + text: "Hello World!" + }, + + getTemplate: function () { + return "helloworld.njk"; + }, + + getTemplateData: function () { + return this.config; + } +}); diff --git a/mm/mounts/modules/default/helloworld/helloworld.njk b/mm/mounts/modules/default/helloworld/helloworld.njk new file mode 100644 index 0000000..005ca28 --- /dev/null +++ b/mm/mounts/modules/default/helloworld/helloworld.njk @@ -0,0 +1,5 @@ + +
{{text | safe}}
diff --git a/mm/mounts/modules/default/newsfeed/README.md b/mm/mounts/modules/default/newsfeed/README.md new file mode 100644 index 0000000..0671f13 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/README.md @@ -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). diff --git a/mm/mounts/modules/default/newsfeed/fullarticle.njk b/mm/mounts/modules/default/newsfeed/fullarticle.njk new file mode 100644 index 0000000..6570396 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/fullarticle.njk @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/mm/mounts/modules/default/newsfeed/newsfeed.css b/mm/mounts/modules/default/newsfeed/newsfeed.css new file mode 100644 index 0000000..2c690a4 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/newsfeed.css @@ -0,0 +1,24 @@ +iframe.newsfeed-fullarticle { + width: 100vw; + + /* very large height value to allow scrolling */ + height: 3000px; + top: 0; + left: 0; + border: none; + z-index: 1; +} + +.region.bottom.bar.newsfeed-fullarticle { + bottom: inherit; + top: -90px; +} + +.newsfeed-list { + list-style: none; +} + +.newsfeed-list li { + text-align: justify; + margin-bottom: 0.5em; +} diff --git a/mm/mounts/modules/default/newsfeed/newsfeed.js b/mm/mounts/modules/default/newsfeed/newsfeed.js new file mode 100644 index 0000000..eee0b44 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/newsfeed.js @@ -0,0 +1,409 @@ +/* MagicMirror² + * Module: NewsFeed + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +Module.register("newsfeed", { + // Default module config. + defaults: { + feeds: [ + { + title: "New York Times", + url: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml", + encoding: "UTF-8" //ISO-8859-1 + } + ], + showAsList: false, + showSourceTitle: true, + showPublishDate: true, + broadcastNewsFeeds: true, + broadcastNewsUpdates: true, + showDescription: false, + showTitleAsUrl: false, + wrapTitle: true, + wrapDescription: true, + truncDescription: true, + lengthDescription: 400, + hideLoading: false, + reloadInterval: 5 * 60 * 1000, // every 5 minutes + updateInterval: 10 * 1000, + animationSpeed: 2.5 * 1000, + maxNewsItems: 0, // 0 for unlimited + ignoreOldItems: false, + ignoreOlderThan: 24 * 60 * 60 * 1000, // 1 day + removeStartTags: "", + removeEndTags: "", + startTags: [], + endTags: [], + prohibitedWords: [], + scrollLength: 500, + logFeedWarnings: false, + dangerouslyDisableAutoEscaping: false + }, + + getUrlPrefix: function (item) { + if (item.useCorsProxy) { + return `${location.protocol}//${location.host}/cors?url=`; + } else { + return ""; + } + }, + + // Define required scripts. + getScripts: function () { + return ["moment.js"]; + }, + + //Define required styles. + getStyles: function () { + return ["newsfeed.css"]; + }, + + // Define required translations. + getTranslations: function () { + // The translations for the default modules are defined in the core translation files. + // Therefor we can just return false. Otherwise we should have returned a dictionary. + // If you're trying to build your own module including translations, check out the documentation. + return false; + }, + + // Define start sequence. + start: function () { + Log.info(`Starting module: ${this.name}`); + + // Set locale. + moment.locale(config.language); + + this.newsItems = []; + this.loaded = false; + this.error = null; + this.activeItem = 0; + this.scrollPosition = 0; + + this.registerFeeds(); + + this.isShowingDescription = this.config.showDescription; + }, + + // Override socket notification handler. + socketNotificationReceived: function (notification, payload) { + if (notification === "NEWS_ITEMS") { + this.generateFeed(payload); + + if (!this.loaded) { + if (this.config.hideLoading) { + this.show(); + } + this.scheduleUpdateInterval(); + } + + this.loaded = true; + this.error = null; + } else if (notification === "NEWSFEED_ERROR") { + this.error = this.translate(payload.error_type); + this.scheduleUpdateInterval(); + } + }, + + //Override fetching of template name + getTemplate: function () { + if (this.config.feedUrl) { + return "oldconfig.njk"; + } else if (this.config.showFullArticle) { + return "fullarticle.njk"; + } + return "newsfeed.njk"; + }, + + //Override template data and return whats used for the current template + getTemplateData: function () { + // this.config.showFullArticle is a run-time configuration, triggered by optional notifications + if (this.config.showFullArticle) { + return { + url: this.getActiveItemURL() + }; + } + if (this.error) { + return { + error: this.error + }; + } + if (this.newsItems.length === 0) { + return { + empty: true + }; + } + if (this.activeItem >= this.newsItems.length) { + this.activeItem = 0; + } + + const item = this.newsItems[this.activeItem]; + const items = this.newsItems.map(function (item) { + item.publishDate = moment(new Date(item.pubdate)).fromNow(); + return item; + }); + + return { + loaded: true, + config: this.config, + sourceTitle: item.sourceTitle, + publishDate: moment(new Date(item.pubdate)).fromNow(), + title: item.title, + url: this.getUrlPrefix(item) + item.url, + description: item.description, + items: items + }; + }, + + getActiveItemURL: function () { + const item = this.newsItems[this.activeItem]; + if (item) { + return typeof item.url === "string" ? this.getUrlPrefix(item) + item.url : this.getUrlPrefix(item) + item.url.href; + } else { + return ""; + } + }, + + /** + * Registers the feeds to be used by the backend. + */ + registerFeeds: function () { + for (let feed of this.config.feeds) { + this.sendSocketNotification("ADD_FEED", { + feed: feed, + config: this.config + }); + } + }, + + /** + * Generate an ordered list of items for this configured module. + * @param {object} feeds An object with feeds returned by the node helper. + */ + generateFeed: function (feeds) { + let newsItems = []; + for (let feed in feeds) { + const feedItems = feeds[feed]; + if (this.subscribedToFeed(feed)) { + for (let item of feedItems) { + item.sourceTitle = this.titleForFeed(feed); + if (!(this.config.ignoreOldItems && Date.now() - new Date(item.pubdate) > this.config.ignoreOlderThan)) { + newsItems.push(item); + } + } + } + } + newsItems.sort(function (a, b) { + const dateA = new Date(a.pubdate); + const dateB = new Date(b.pubdate); + return dateB - dateA; + }); + + if (this.config.maxNewsItems > 0) { + newsItems = newsItems.slice(0, this.config.maxNewsItems); + } + + if (this.config.prohibitedWords.length > 0) { + newsItems = newsItems.filter(function (item) { + for (let word of this.config.prohibitedWords) { + if (item.title.toLowerCase().indexOf(word.toLowerCase()) > -1) { + return false; + } + } + return true; + }, this); + } + newsItems.forEach((item) => { + //Remove selected tags from the beginning of rss feed items (title or description) + if (this.config.removeStartTags === "title" || this.config.removeStartTags === "both") { + for (let startTag of this.config.startTags) { + if (item.title.slice(0, startTag.length) === startTag) { + item.title = item.title.slice(startTag.length, item.title.length); + } + } + } + + if (this.config.removeStartTags === "description" || this.config.removeStartTags === "both") { + if (this.isShowingDescription) { + for (let startTag of this.config.startTags) { + if (item.description.slice(0, startTag.length) === startTag) { + item.description = item.description.slice(startTag.length, item.description.length); + } + } + } + } + + //Remove selected tags from the end of rss feed items (title or description) + if (this.config.removeEndTags) { + for (let endTag of this.config.endTags) { + if (item.title.slice(-endTag.length) === endTag) { + item.title = item.title.slice(0, -endTag.length); + } + } + + if (this.isShowingDescription) { + for (let endTag of this.config.endTags) { + if (item.description.slice(-endTag.length) === endTag) { + item.description = item.description.slice(0, -endTag.length); + } + } + } + } + }); + + // get updated news items and broadcast them + const updatedItems = []; + newsItems.forEach((value) => { + if (this.newsItems.findIndex((value1) => value1 === value) === -1) { + // Add item to updated items list + updatedItems.push(value); + } + }); + + // check if updated items exist, if so and if we should broadcast these updates, then lets do so + if (this.config.broadcastNewsUpdates && updatedItems.length > 0) { + this.sendNotification("NEWS_FEED_UPDATE", { items: updatedItems }); + } + + this.newsItems = newsItems; + }, + + /** + * Check if this module is configured to show this feed. + * @param {string} feedUrl Url of the feed to check. + * @returns {boolean} True if it is subscribed, false otherwise + */ + subscribedToFeed: function (feedUrl) { + for (let feed of this.config.feeds) { + if (feed.url === feedUrl) { + return true; + } + } + return false; + }, + + /** + * Returns title for the specific feed url. + * @param {string} feedUrl Url of the feed + * @returns {string} The title of the feed + */ + titleForFeed: function (feedUrl) { + for (let feed of this.config.feeds) { + if (feed.url === feedUrl) { + return feed.title || ""; + } + } + return ""; + }, + + /** + * Schedule visual update. + */ + scheduleUpdateInterval: function () { + this.updateDom(this.config.animationSpeed); + + // Broadcast NewsFeed if needed + if (this.config.broadcastNewsFeeds) { + this.sendNotification("NEWS_FEED", { items: this.newsItems }); + } + + // #2638 Clear timer if it already exists + if (this.timer) clearInterval(this.timer); + + this.timer = setInterval(() => { + this.activeItem++; + this.updateDom(this.config.animationSpeed); + + // Broadcast NewsFeed if needed + if (this.config.broadcastNewsFeeds) { + this.sendNotification("NEWS_FEED", { items: this.newsItems }); + } + }, this.config.updateInterval); + }, + + resetDescrOrFullArticleAndTimer: function () { + this.isShowingDescription = this.config.showDescription; + this.config.showFullArticle = false; + this.scrollPosition = 0; + // reset bottom bar alignment + document.getElementsByClassName("region bottom bar")[0].classList.remove("newsfeed-fullarticle"); + if (!this.timer) { + this.scheduleUpdateInterval(); + } + }, + + notificationReceived: function (notification, payload, sender) { + const before = this.activeItem; + if (notification === "MODULE_DOM_CREATED" && this.config.hideLoading) { + this.hide(); + } else if (notification === "ARTICLE_NEXT") { + this.activeItem++; + if (this.activeItem >= this.newsItems.length) { + this.activeItem = 0; + } + this.resetDescrOrFullArticleAndTimer(); + Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`); + this.updateDom(100); + } else if (notification === "ARTICLE_PREVIOUS") { + this.activeItem--; + if (this.activeItem < 0) { + this.activeItem = this.newsItems.length - 1; + } + this.resetDescrOrFullArticleAndTimer(); + Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`); + this.updateDom(100); + } + // if "more details" is received the first time: show article summary, on second time show full article + else if (notification === "ARTICLE_MORE_DETAILS") { + // full article is already showing, so scrolling down + if (this.config.showFullArticle === true) { + this.scrollPosition += this.config.scrollLength; + window.scrollTo(0, this.scrollPosition); + Log.debug(`${this.name} - scrolling down`); + Log.debug(`${this.name} - ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`); + } else { + this.showFullArticle(); + } + } else if (notification === "ARTICLE_SCROLL_UP") { + if (this.config.showFullArticle === true) { + this.scrollPosition -= this.config.scrollLength; + window.scrollTo(0, this.scrollPosition); + Log.debug(`${this.name} - scrolling up`); + Log.debug(`${this.name} - ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`); + } + } else if (notification === "ARTICLE_LESS_DETAILS") { + this.resetDescrOrFullArticleAndTimer(); + Log.debug(`${this.name} - showing only article titles again`); + this.updateDom(100); + } else if (notification === "ARTICLE_TOGGLE_FULL") { + if (this.config.showFullArticle) { + this.activeItem++; + this.resetDescrOrFullArticleAndTimer(); + } else { + this.showFullArticle(); + } + } else if (notification === "ARTICLE_INFO_REQUEST") { + this.sendNotification("ARTICLE_INFO_RESPONSE", { + title: this.newsItems[this.activeItem].title, + source: this.newsItems[this.activeItem].sourceTitle, + date: this.newsItems[this.activeItem].pubdate, + desc: this.newsItems[this.activeItem].description, + url: this.getActiveItemURL() + }); + } + }, + + showFullArticle: function () { + this.isShowingDescription = !this.isShowingDescription; + this.config.showFullArticle = !this.isShowingDescription; + // make bottom bar align to top to allow scrolling + if (this.config.showFullArticle === true) { + document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle"); + } + clearInterval(this.timer); + this.timer = null; + Log.debug(`${this.name} - showing ${this.isShowingDescription ? "article description" : "full article"}`); + this.updateDom(100); + } +}); diff --git a/mm/mounts/modules/default/newsfeed/newsfeed.njk b/mm/mounts/modules/default/newsfeed/newsfeed.njk new file mode 100644 index 0000000..9e7e9d7 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/newsfeed.njk @@ -0,0 +1,93 @@ +{% macro escapeText(text, dangerouslyDisableAutoEscaping=false) %} + {% if dangerouslyDisableAutoEscaping %} + {{ text | safe}} + {% else %} + {{ text }} + {% endif %} +{% endmacro %} + +{% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %} + {% if dangerouslyDisableAutoEscaping %} + {% if showTitleAsUrl %} + {{ title | safe }} + {% else %} + {{ title | safe}} + {% endif %} + {% else %} + {% if showTitleAsUrl %} + {{ title }} + {% else %} + {{ title }} + {% endif %} + {% endif %} +{% endmacro %} + +{% if loaded %} + {% if config.showAsList %} + + {% else %} +
+ {% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %} +
+ {% if sourceTitle and config.showSourceTitle %} + {{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %}, {% else %}: {% endif %} + {% endif %} + {% if config.showPublishDate %} + {{ publishDate }}: + {% endif %} +
+ {% endif %} +
+ {{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }} +
+ {% if config.showDescription %} +
+ {% if config.truncDescription %} + {{ escapeText(description | truncate(config.lengthDescription), config.dangerouslyDisableAutoEscaping) }} + {% else %} + {{ escapeText(description, config.dangerouslyDisableAutoEscaping) }} + {% endif %} +
+ {% endif %} +
+ {% endif %} +{% elseif empty %} +
+ {{ "NEWSFEED_NO_ITEMS" | translate | safe }} +
+{% elseif error %} +
+ {{ "MODULE_CONFIG_ERROR" | translate({MODULE_NAME: "Newsfeed", ERROR: error}) | safe }} +
+{% else %} +
+ {{ "LOADING" | translate | safe }} +
+{% endif %} diff --git a/mm/mounts/modules/default/newsfeed/newsfeedfetcher.js b/mm/mounts/modules/default/newsfeed/newsfeedfetcher.js new file mode 100644 index 0000000..51d38f8 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/newsfeedfetcher.js @@ -0,0 +1,183 @@ +/* MagicMirror² + * Node Helper: Newsfeed - NewsfeedFetcher + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ + +const stream = require("stream"); +const FeedMe = require("feedme"); +const iconv = require("iconv-lite"); +const fetch = require("fetch"); +const Log = require("logger"); +const NodeHelper = require("node_helper"); + +/** + * Responsible for requesting an update on the set interval and broadcasting the data. + * @param {string} url URL of the news feed. + * @param {number} reloadInterval Reload interval in milliseconds. + * @param {string} encoding Encoding of the feed. + * @param {boolean} logFeedWarnings If true log warnings when there is an error parsing a news article. + * @param {boolean} useCorsProxy If true cors proxy is used for article url's. + * @class + */ +const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy) { + let reloadTimer = null; + let items = []; + let reloadIntervalMS = reloadInterval; + + let fetchFailedCallback = function () {}; + let itemsReceivedCallback = function () {}; + + if (reloadIntervalMS < 1000) { + reloadIntervalMS = 1000; + } + + /* private methods */ + + /** + * Request the new items. + */ + const fetchNews = () => { + clearTimeout(reloadTimer); + reloadTimer = null; + items = []; + + const parser = new FeedMe(); + + parser.on("item", (item) => { + const title = item.title; + let description = item.description || item.summary || item.content || ""; + const pubdate = item.pubdate || item.published || item.updated || item["dc:date"]; + const url = item.url || item.link || ""; + + if (title && pubdate) { + const regex = /(<([^>]+)>)/gi; + description = description.toString().replace(regex, ""); + + items.push({ + title: title, + description: description, + pubdate: pubdate, + url: url, + useCorsProxy: useCorsProxy + }); + } else if (logFeedWarnings) { + Log.warn("Can't parse feed item:"); + Log.warn(item); + Log.warn(`Title: ${title}`); + Log.warn(`Description: ${description}`); + Log.warn(`Pubdate: ${pubdate}`); + } + }); + + parser.on("end", () => { + this.broadcastItems(); + }); + + parser.on("error", (error) => { + fetchFailedCallback(this, error); + scheduleTimer(); + }); + + //"end" event is not broadcast if the feed is empty but "finish" is used for both + parser.on("finish", () => { + scheduleTimer(); + }); + + parser.on("ttl", (minutes) => { + try { + // 86400000 = 24 hours is mentioned in the docs as maximum value: + const ttlms = Math.min(minutes * 60 * 1000, 86400000); + if (ttlms > reloadIntervalMS) { + reloadIntervalMS = ttlms; + Log.info(`Newsfeed-Fetcher: reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`); + } + } catch (error) { + Log.warn(`Newsfeed-Fetcher: feed ttl is no valid integer=${minutes} for url ${url}`); + } + }); + + const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]); + const headers = { + "User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`, + "Cache-Control": "max-age=0, no-cache, no-store, must-revalidate", + Pragma: "no-cache" + }; + + fetch(url, { headers: headers }) + .then(NodeHelper.checkFetchStatus) + .then((response) => { + let nodeStream; + if (response.body instanceof stream.Readable) { + nodeStream = response.body; + } else { + nodeStream = stream.Readable.fromWeb(response.body); + } + nodeStream.pipe(iconv.decodeStream(encoding)).pipe(parser); + }) + .catch((error) => { + fetchFailedCallback(this, error); + scheduleTimer(); + }); + }; + + /** + * Schedule the timer for the next update. + */ + const scheduleTimer = function () { + clearTimeout(reloadTimer); + reloadTimer = setTimeout(function () { + fetchNews(); + }, reloadIntervalMS); + }; + + /* public methods */ + + /** + * Update the reload interval, but only if we need to increase the speed. + * @param {number} interval Interval for the update in milliseconds. + */ + this.setReloadInterval = function (interval) { + if (interval > 1000 && interval < reloadIntervalMS) { + reloadIntervalMS = interval; + } + }; + + /** + * Initiate fetchNews(); + */ + this.startFetch = function () { + fetchNews(); + }; + + /** + * Broadcast the existing items. + */ + this.broadcastItems = function () { + if (items.length <= 0) { + Log.info("Newsfeed-Fetcher: No items to broadcast yet."); + return; + } + Log.info(`Newsfeed-Fetcher: Broadcasting ${items.length} items.`); + itemsReceivedCallback(this); + }; + + this.onReceive = function (callback) { + itemsReceivedCallback = callback; + }; + + this.onError = function (callback) { + fetchFailedCallback = callback; + }; + + this.url = function () { + return url; + }; + + this.items = function () { + return items; + }; +}; + +module.exports = NewsfeedFetcher; diff --git a/mm/mounts/modules/default/newsfeed/node_helper.js b/mm/mounts/modules/default/newsfeed/node_helper.js new file mode 100644 index 0000000..64ba592 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/node_helper.js @@ -0,0 +1,86 @@ +/* MagicMirror² + * Node Helper: Newsfeed + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ + +const NodeHelper = require("node_helper"); +const Log = require("logger"); +const NewsfeedFetcher = require("./newsfeedfetcher"); + +module.exports = NodeHelper.create({ + // Override start method. + start: function () { + Log.log(`Starting node helper for: ${this.name}`); + this.fetchers = []; + }, + + // Override socketNotificationReceived received. + socketNotificationReceived: function (notification, payload) { + if (notification === "ADD_FEED") { + this.createFetcher(payload.feed, payload.config); + } + }, + + /** + * Creates a fetcher for a new feed if it doesn't exist yet. + * Otherwise it reuses the existing one. + * @param {object} feed The feed object + * @param {object} config The configuration object + */ + createFetcher: function (feed, config) { + const url = feed.url || ""; + const encoding = feed.encoding || "UTF-8"; + const reloadInterval = feed.reloadInterval || config.reloadInterval || 5 * 60 * 1000; + let useCorsProxy = feed.useCorsProxy; + if (useCorsProxy === undefined) useCorsProxy = true; + + try { + new URL(url); + } catch (error) { + Log.error("Newsfeed Error. Malformed newsfeed url: ", url, error); + this.sendSocketNotification("NEWSFEED_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" }); + return; + } + + let fetcher; + if (typeof this.fetchers[url] === "undefined") { + Log.log(`Create new newsfetcher for url: ${url} - Interval: ${reloadInterval}`); + fetcher = new NewsfeedFetcher(url, reloadInterval, encoding, config.logFeedWarnings, useCorsProxy); + + fetcher.onReceive(() => { + this.broadcastFeeds(); + }); + + fetcher.onError((fetcher, error) => { + Log.error("Newsfeed Error. Could not fetch newsfeed: ", url, error); + let error_type = NodeHelper.checkFetchError(error); + this.sendSocketNotification("NEWSFEED_ERROR", { + error_type + }); + }); + + this.fetchers[url] = fetcher; + } else { + Log.log(`Use existing newsfetcher for url: ${url}`); + fetcher = this.fetchers[url]; + fetcher.setReloadInterval(reloadInterval); + fetcher.broadcastItems(); + } + + fetcher.startFetch(); + }, + + /** + * Creates an object with all feed items of the different registered feeds, + * and broadcasts these using sendSocketNotification. + */ + broadcastFeeds: function () { + const feeds = {}; + for (let f in this.fetchers) { + feeds[f] = this.fetchers[f].items(); + } + this.sendSocketNotification("NEWS_ITEMS", feeds); + } +}); diff --git a/mm/mounts/modules/default/newsfeed/oldconfig.njk b/mm/mounts/modules/default/newsfeed/oldconfig.njk new file mode 100644 index 0000000..db0f8d4 --- /dev/null +++ b/mm/mounts/modules/default/newsfeed/oldconfig.njk @@ -0,0 +1,3 @@ +
+ {{ "MODULE_CONFIG_CHANGED" | translate({MODULE_NAME: "Newsfeed"}) | safe }} +
\ No newline at end of file diff --git a/mm/mounts/modules/default/updatenotification/README.md b/mm/mounts/modules/default/updatenotification/README.md new file mode 100644 index 0000000..d700376 --- /dev/null +++ b/mm/mounts/modules/default/updatenotification/README.md @@ -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). diff --git a/mm/mounts/modules/default/updatenotification/git_helper.js b/mm/mounts/modules/default/updatenotification/git_helper.js new file mode 100644 index 0000000..3628dc4 --- /dev/null +++ b/mm/mounts/modules/default/updatenotification/git_helper.js @@ -0,0 +1,213 @@ +const util = require("util"); +const exec = util.promisify(require("child_process").exec); +const fs = require("fs"); +const path = require("path"); +const Log = require("logger"); + +const BASE_DIR = path.normalize(`${__dirname}/../../../`); + +class GitHelper { + constructor() { + this.gitRepos = []; + this.gitResultList = []; + } + + getRefRegex(branch) { + return new RegExp(`s*([a-z,0-9]+[.][.][a-z,0-9]+) ${branch}`, "g"); + } + + async execShell(command) { + const { stdout = "", stderr = "" } = await exec(command); + + return { stdout, stderr }; + } + + async isGitRepo(moduleFolder) { + const { stderr } = await this.execShell(`cd ${moduleFolder} && git remote -v`); + + if (stderr) { + Log.error(`Failed to fetch git data for ${moduleFolder}: ${stderr}`); + + return false; + } + + return true; + } + + async add(moduleName) { + let moduleFolder = BASE_DIR; + + if (moduleName !== "MagicMirror") { + moduleFolder = `${moduleFolder}modules/${moduleName}`; + } + + try { + Log.info(`Checking git for module: ${moduleName}`); + // Throws error if file doesn't exist + fs.statSync(path.join(moduleFolder, ".git")); + + // Fetch the git or throw error if no remotes + const isGitRepo = await this.isGitRepo(moduleFolder); + + if (isGitRepo) { + // Folder has .git and has at least one git remote, watch this folder + this.gitRepos.push({ module: moduleName, folder: moduleFolder }); + } + } catch (err) { + // Error when directory .git doesn't exist or doesn't have any remotes + // This module is not managed with git, skip + } + } + + async getStatusInfo(repo) { + let gitInfo = { + module: repo.module, + behind: 0, // commits behind + current: "", // branch name + hash: "", // current hash + tracking: "", // remote branch + isBehindInStatus: false + }; + + if (repo.module === "MagicMirror") { + // the hash is only needed for the mm repo + const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git rev-parse HEAD`); + + if (stderr) { + Log.error(`Failed to get current commit hash for ${repo.module}: ${stderr}`); + } + + gitInfo.hash = stdout; + } + + const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git status -sb`); + + if (stderr) { + Log.error(`Failed to get git status for ${repo.module}: ${stderr}`); + // exit without git status info + return; + } + + // only the first line of stdout is evaluated + let status = stdout.split("\n")[0]; + // examples for status: + // ## develop...origin/develop + // ## master...origin/master [behind 8] + // ## master...origin/master [ahead 8, behind 1] + // ## HEAD (no branch) + status = status.match(/## (.*)\.\.\.([^ ]*)(?: .*behind (\d+))?/); + // examples for status: + // [ '## develop...origin/develop', 'develop', 'origin/develop' ] + // [ '## master...origin/master [behind 8]', 'master', 'origin/master', '8' ] + // [ '## master...origin/master [ahead 8, behind 1]', 'master', 'origin/master', '1' ] + if (status) { + gitInfo.current = status[1]; + gitInfo.tracking = status[2]; + + if (status[3]) { + // git fetch was already called before so `git status -sb` delivers already the behind number + gitInfo.behind = parseInt(status[3]); + gitInfo.isBehindInStatus = true; + } + } + + return gitInfo; + } + + async getRepoInfo(repo) { + const gitInfo = await this.getStatusInfo(repo); + + if (!gitInfo || !gitInfo.current) { + return; + } + + if (gitInfo.isBehindInStatus && (gitInfo.module !== "MagicMirror" || gitInfo.current !== "master")) { + return gitInfo; + } + + const { stderr } = await this.execShell(`cd ${repo.folder} && git fetch -n --dry-run`); + + // example output: + // From https://github.com/MichMich/MagicMirror + // e40ddd4..06389e3 develop -> origin/develop + // here the result is in stderr (this is a git default, don't ask why ...) + const matches = stderr.match(this.getRefRegex(gitInfo.current)); + + // this is the default if there was no match from "git fetch -n --dry-run". + // Its a fallback because if there was a real "git fetch", the above "git fetch -n --dry-run" would deliver nothing. + let refDiff = `${gitInfo.current}..origin/${gitInfo.current}`; + if (matches && matches[0]) { + refDiff = matches[0]; + } + + // get behind with refs + try { + const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path --count ${refDiff}`); + gitInfo.behind = parseInt(stdout); + + // for MagicMirror-Repo and "master" branch avoid getting notified when no tag is in refDiff + // so only releases are reported and we can change e.g. the README.md without sending notifications + if (gitInfo.behind > 0 && gitInfo.module === "MagicMirror" && gitInfo.current === "master") { + let tagList = ""; + try { + const { stdout } = await this.execShell(`cd ${repo.folder} && git ls-remote -q --tags --refs`); + tagList = stdout.trim(); + } catch (err) { + Log.error(`Failed to get tag list for ${repo.module}: ${err}`); + } + // check if tag is between commits and only report behind > 0 if so + try { + const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path ${refDiff}`); + let cnt = 0; + for (const ref of stdout.trim().split("\n")) { + if (tagList.includes(ref)) cnt++; // tag found + } + if (cnt === 0) gitInfo.behind = 0; + } catch (err) { + Log.error(`Failed to get git revisions for ${repo.module}: ${err}`); + } + } + + return gitInfo; + } catch (err) { + Log.error(`Failed to get git revisions for ${repo.module}: ${err}`); + } + } + + async getRepos() { + this.gitResultList = []; + + for (const repo of this.gitRepos) { + try { + const gitInfo = await this.getRepoInfo(repo); + + if (gitInfo) { + this.gitResultList.push(gitInfo); + } + } catch (e) { + Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`); + } + } + + return this.gitResultList; + } + + async checkUpdates() { + var updates = []; + + const allRepos = await this.gitResultList.map((module) => { + return new Promise((resolve) => { + if (module.behind > 0 && module.module !== "MagicMirror") { + Log.info(`Update found for module: ${module.module}`); + updates.push(module); + } + resolve(module); + }); + }); + await Promise.all(allRepos); + + return updates; + } +} + +module.exports = GitHelper; diff --git a/mm/mounts/modules/default/updatenotification/node_helper.js b/mm/mounts/modules/default/updatenotification/node_helper.js new file mode 100644 index 0000000..607e145 --- /dev/null +++ b/mm/mounts/modules/default/updatenotification/node_helper.js @@ -0,0 +1,87 @@ +const NodeHelper = require("node_helper"); +const defaultModules = require("../defaultmodules"); +const GitHelper = require("./git_helper"); + +const ONE_MINUTE = 60 * 1000; + +module.exports = NodeHelper.create({ + config: {}, + + updateTimer: null, + updateProcessStarted: false, + + gitHelper: new GitHelper(), + + async configureModules(modules) { + for (const moduleName of modules) { + if (!this.ignoreUpdateChecking(moduleName)) { + await this.gitHelper.add(moduleName); + } + } + + if (!this.ignoreUpdateChecking("MagicMirror")) { + await this.gitHelper.add("MagicMirror"); + } + }, + + async socketNotificationReceived(notification, payload) { + switch (notification) { + case "CONFIG": + this.config = payload; + break; + case "MODULES": + // if this is the 1st time thru the update check process + if (!this.updateProcessStarted) { + this.updateProcessStarted = true; + await this.configureModules(payload); + await this.performFetch(); + } + break; + case "SCAN_UPDATES": + // 1st time of check allows to force new scan + if (this.updateProcessStarted) { + clearTimeout(this.updateTimer); + await this.performFetch(); + } + break; + } + }, + + async performFetch() { + const repos = await this.gitHelper.getRepos(); + + for (const repo of repos) { + this.sendSocketNotification("STATUS", repo); + } + + if (this.config.sendUpdatesNotifications) { + const updates = await this.gitHelper.checkUpdates(); + if (updates.length) this.sendSocketNotification("UPDATES", updates); + } + + this.scheduleNextFetch(this.config.updateInterval); + }, + + scheduleNextFetch(delay) { + clearTimeout(this.updateTimer); + + this.updateTimer = setTimeout(() => { + this.performFetch(); + }, Math.max(delay, ONE_MINUTE)); + }, + + ignoreUpdateChecking(moduleName) { + // Should not check for updates for default modules + if (defaultModules.includes(moduleName)) { + return true; + } + + // Should not check for updates for ignored modules + if (this.config.ignoreModules.includes(moduleName)) { + return true; + } + + // The rest of the modules that passes should check for updates + return false; + } +}); diff --git a/mm/mounts/modules/default/updatenotification/updatenotification.css b/mm/mounts/modules/default/updatenotification/updatenotification.css new file mode 100644 index 0000000..deed9e6 --- /dev/null +++ b/mm/mounts/modules/default/updatenotification/updatenotification.css @@ -0,0 +1,3 @@ +.module.updatenotification a.difflink { + text-decoration: none; +} diff --git a/mm/mounts/modules/default/updatenotification/updatenotification.js b/mm/mounts/modules/default/updatenotification/updatenotification.js new file mode 100644 index 0000000..73327ec --- /dev/null +++ b/mm/mounts/modules/default/updatenotification/updatenotification.js @@ -0,0 +1,100 @@ +/* MagicMirror² + * Module: UpdateNotification + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +Module.register("updatenotification", { + defaults: { + updateInterval: 10 * 60 * 1000, // every 10 minutes + refreshInterval: 24 * 60 * 60 * 1000, // one day + ignoreModules: [], + sendUpdatesNotifications: false + }, + + suspended: false, + moduleList: {}, + + start() { + Log.info(`Starting module: ${this.name}`); + this.addFilters(); + setInterval(() => { + this.moduleList = {}; + this.updateDom(2); + }, this.config.refreshInterval); + }, + + suspend() { + this.suspended = true; + }, + + resume() { + this.suspended = false; + this.updateDom(2); + }, + + notificationReceived(notification) { + switch (notification) { + case "DOM_OBJECTS_CREATED": + this.sendSocketNotification("CONFIG", this.config); + this.sendSocketNotification("MODULES", Object.keys(Module.definitions)); + break; + case "SCAN_UPDATES": + this.sendSocketNotification("SCAN_UPDATES"); + break; + } + }, + + socketNotificationReceived(notification, payload) { + switch (notification) { + case "STATUS": + this.updateUI(payload); + break; + case "UPDATES": + this.sendNotification("UPDATES", payload); + break; + } + }, + + getStyles() { + return [`${this.name}.css`]; + }, + + getTemplate() { + return `${this.name}.njk`; + }, + + getTemplateData() { + return { moduleList: this.moduleList, suspended: this.suspended }; + }, + + updateUI(payload) { + if (payload && payload.behind > 0) { + // if we haven't seen info for this module + if (this.moduleList[payload.module] === undefined) { + // save it + this.moduleList[payload.module] = payload; + this.updateDom(2); + } + } else if (payload && payload.behind === 0) { + // if the module WAS in the list, but shouldn't be + if (this.moduleList[payload.module] !== undefined) { + // remove it + delete this.moduleList[payload.module]; + this.updateDom(2); + } + } + }, + + addFilters() { + this.nunjucksEnvironment().addFilter("diffLink", (text, status) => { + if (status.module !== "MagicMirror") { + return text; + } + + const localRef = status.hash; + const remoteRef = status.tracking.replace(/.*\//, ""); + return `${text}`; + }); + } +}); diff --git a/mm/mounts/modules/default/updatenotification/updatenotification.njk b/mm/mounts/modules/default/updatenotification/updatenotification.njk new file mode 100644 index 0000000..77d7975 --- /dev/null +++ b/mm/mounts/modules/default/updatenotification/updatenotification.njk @@ -0,0 +1,15 @@ +{% if not suspended %} + {% for name, status in moduleList %} +
+ + + {% set mainTextLabel = "UPDATE_NOTIFICATION" if name === "MagicMirror" else "UPDATE_NOTIFICATION_MODULE" %} + {{ mainTextLabel | translate({MODULE_NAME: name}) }} + +
+
+ {% 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 }} +
+ {% endfor %} +{% endif %} diff --git a/mm/mounts/modules/default/utils.js b/mm/mounts/modules/default/utils.js new file mode 100644 index 0000000..e60d96e --- /dev/null +++ b/mm/mounts/modules/default/utils.js @@ -0,0 +1,172 @@ +/** + * A function to make HTTP requests via the server to avoid CORS-errors. + * @param {string} url the url to fetch from + * @param {string} type what contenttype to expect in the response, can be "json" or "xml" + * @param {boolean} useCorsProxy A flag to indicate + * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send + * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive + * @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property). + */ +async function performWebRequest(url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined) { + const request = {}; + let requestUrl; + if (useCorsProxy) { + requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders); + } else { + requestUrl = url; + request.headers = getHeadersToSend(requestHeaders); + } + const response = await fetch(requestUrl, request); + const data = await response.text(); + + if (type === "xml") { + return new DOMParser().parseFromString(data, "text/html"); + } else { + if (!data || !data.length > 0) return undefined; + + const dataResponse = JSON.parse(data); + if (!dataResponse.headers) { + dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response); + } + return dataResponse; + } +} + +/** + * Gets a URL that will be used when calling the CORS-method on the server. + * @param {string} url the url to fetch from + * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send + * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive + * @returns {string} to be used as URL when calling CORS-method on server. + */ +const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders) { + if (!url || url.length < 1) { + throw new Error(`Invalid URL: ${url}`); + } else { + let corsUrl = `${location.protocol}//${location.host}/cors?`; + + const requestHeaderString = getRequestHeaderString(requestHeaders); + if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`; + + const expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders); + if (requestHeaderString && expectedResponseHeadersString) { + corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`; + } else if (expectedResponseHeadersString) { + corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`; + } + + if (requestHeaderString || expectedResponseHeadersString) { + return `${corsUrl}&url=${url}`; + } + return `${corsUrl}url=${url}`; + } +}; + +/** + * Gets the part of the CORS URL that represents the HTTP headers to send. + * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send + * @returns {string} to be used as request-headers component in CORS URL. + */ +const getRequestHeaderString = function (requestHeaders) { + let requestHeaderString = ""; + if (requestHeaders) { + for (const header of requestHeaders) { + if (requestHeaderString.length === 0) { + requestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`; + } else { + requestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`; + } + } + return requestHeaderString; + } + return undefined; +}; + +/** + * Gets headers and values to attach to the web request. + * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send + * @returns {object} An object specifying name and value of the headers. + */ +const getHeadersToSend = (requestHeaders) => { + const headersToSend = {}; + if (requestHeaders) { + for (const header of requestHeaders) { + headersToSend[header.name] = header.value; + } + } + + return headersToSend; +}; + +/** + * Gets the part of the CORS URL that represents the expected HTTP headers to receive. + * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive + * @returns {string} to be used as the expected HTTP-headers component in CORS URL. + */ +const getExpectedResponseHeadersString = function (expectedResponseHeaders) { + let expectedResponseHeadersString = ""; + if (expectedResponseHeaders) { + for (const header of expectedResponseHeaders) { + if (expectedResponseHeadersString.length === 0) { + expectedResponseHeadersString = `${header}`; + } else { + expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`; + } + } + return expectedResponseHeaders; + } + return undefined; +}; + +/** + * Gets the values for the expected headers from the response. + * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive + * @param {Response} response the HTTP response + * @returns {string} to be used as the expected HTTP-headers component in CORS URL. + */ +const getHeadersFromResponse = (expectedResponseHeaders, response) => { + const responseHeaders = []; + + if (expectedResponseHeaders) { + for (const header of expectedResponseHeaders) { + const headerValue = response.headers.get(header); + responseHeaders.push({ name: header, value: headerValue }); + } + } + + return responseHeaders; +}; + +/** + * Format the time according to the config + * @param {object} config The config of the module + * @param {object} time time to format + * @returns {string} The formatted time string + */ +const formatTime = (config, time) => { + let date = moment(time); + + if (config.timezone) { + date = date.tz(config.timezone); + } + + if (config.timeFormat !== 24) { + if (config.showPeriod) { + if (config.showPeriodUpper) { + return date.format("h:mm A"); + } else { + return date.format("h:mm a"); + } + } else { + return date.format("h:mm"); + } + } + + return date.format("HH:mm"); +}; + +if (typeof module !== "undefined") + module.exports = { + performWebRequest, + formatTime + }; diff --git a/mm/mounts/modules/default/weather/README.md b/mm/mounts/modules/default/weather/README.md new file mode 100644 index 0000000..7effbb1 --- /dev/null +++ b/mm/mounts/modules/default/weather/README.md @@ -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). diff --git a/mm/mounts/modules/default/weather/current.njk b/mm/mounts/modules/default/weather/current.njk new file mode 100644 index 0000000..09781db --- /dev/null +++ b/mm/mounts/modules/default/weather/current.njk @@ -0,0 +1,89 @@ +{% if current %} + {% if not config.onlyTemp %} +
+ + + {{ current.windSpeed | unit("wind") | round }} + {% if config.showWindDirection %} + + {% if config.showWindDirectionAsArrow %} + + {% else %} + {{ current.cardinalWindDirection() | translate }} + {% endif %} +   + + {% endif %} + + {% if config.showHumidity and current.humidity %} + {{ current.humidity | decimalSymbol }}  + {% endif %} + {% if config.showSun %} + + + {% if current.nextSunAction() === "sunset" %} + {{ current.sunset | formatTime }} + {% else %} + {{ current.sunrise | formatTime }} + {% endif %} + + {% endif %} + {% if config.showUVIndex %} + +
+ {{ current.uv_index }} + + {% endif %} +
+ {% endif %} +
+ + + {{ current.temperature | roundValue | unit("temperature") | decimalSymbol }} + +
+
+ {% if config.showIndoorTemperature and indoor.temperature %} +
+ + + {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} + +
+ {% endif %} + {% if config.showIndoorHumidity and indoor.humidity %} +
+ + + {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} + +
+ {% endif %} +
+ {% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %} +
+ {% if config.showFeelsLike %} + + {{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }} +
+ {% endif %} + {% if config.showPrecipitationAmount and current.precipitationAmount %} + + {{ "PRECIP_AMOUNT" | translate }} {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }} +
+ {% endif %} + {% if config.showPrecipitationProbability and current.precipitationProbability %} + + {{ "PRECIP_POP" | translate }} {{ current.precipitationProbability }}% + + {% endif %} +
+ {% endif %} +{% else %} +
+ {{ "LOADING" | translate }} +
+{% endif %} + + + diff --git a/mm/mounts/modules/default/weather/forecast.njk b/mm/mounts/modules/default/weather/forecast.njk new file mode 100644 index 0000000..af5825e --- /dev/null +++ b/mm/mounts/modules/default/weather/forecast.njk @@ -0,0 +1,52 @@ +{% if forecast %} + {% set numSteps = forecast | calcNumSteps %} + {% set currentStep = 0 %} + + {% if config.ignoreToday %} + {% set forecast = forecast.splice(1) %} + {% endif %} + {% set forecast = forecast.slice(0, numSteps) %} + {% for f in forecast %} + + {% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %} + + {% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %} + + {% else %} + + {% endif %} + + + + {% if config.showPrecipitationAmount %} + + {% endif %} + {% if config.showPrecipitationProbability %} + + {% endif %} + {% if config.showUVIndex %} + + {% endif %} + + {% set currentStep = currentStep + 1 %} + {% endfor %} +
{{ "TODAY" | translate }}{{ "TOMORROW" | translate }}{{ f.date.format('ddd') }} + {{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }} + + {{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }} + + {{ f.precipitationAmount | unit("precip", f.precipitationUnits) }} + + {{ f.precipitationProbability | unit("precip", "%") }} + + {{ f.uv_index }} + +
+{% else %} +
+ {{ "LOADING" | translate }} +
+{% endif %} + + + diff --git a/mm/mounts/modules/default/weather/hourly.njk b/mm/mounts/modules/default/weather/hourly.njk new file mode 100644 index 0000000..51fb67d --- /dev/null +++ b/mm/mounts/modules/default/weather/hourly.njk @@ -0,0 +1,42 @@ +{% if hourly %} + {% set numSteps = hourly | calcNumEntries %} + {% set currentStep = 0 %} + + {% set hours = hourly.slice(0, numSteps) %} + {% for hour in hours %} + + + + + {% if config.showUVIndex %} + + {% endif %} + {% if config.showPrecipitationAmount %} + + {% endif %} + {% if config.showPrecipitationProbability %} + + {% endif %} + + {% set currentStep = currentStep + 1 %} + {% endfor %} +
{{ hour.date | formatTime }} + {{ hour.temperature | roundValue | unit("temperature") }} + + {% if hour.uv_index!=0 %} + {{ hour.uv_index }} + + {% endif %} + + {{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }} + + {{ hour.precipitationProbability | unit("precip", "%") }} +
+{% else %} +
+ {{ "LOADING" | translate }} +
+{% endif %} + + + diff --git a/mm/mounts/modules/default/weather/providers/README.md b/mm/mounts/modules/default/weather/providers/README.md new file mode 100644 index 0000000..faa60a0 --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/README.md @@ -0,0 +1,3 @@ +# Weather Module Weather Provider Development Documentation + +For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/development/weather-provider.html). diff --git a/mm/mounts/modules/default/weather/providers/envcanada.js b/mm/mounts/modules/default/weather/providers/envcanada.js new file mode 100644 index 0000000..4c0bf02 --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/envcanada.js @@ -0,0 +1,572 @@ +/* global WeatherProvider, WeatherObject, WeatherUtils */ + +/* MagicMirror² + * Module: Weather + * Provider: Environment Canada (EC) + * + * This class is a provider for Environment Canada MSC Datamart + * Note that this is only for Canadian locations and does not require an API key (access is anonymous) + * + * EC Documentation at following links: + * https://dd.weather.gc.ca/citypage_weather/schema/ + * https://eccc-msc.github.io/open-data/msc-datamart/readme_en/ + * + * This module supports Canadian locations only and requires 2 additional config parameters: + * + * siteCode - the city/town unique identifier for which weather is to be displayed. Format is 's0000000'. + * + * provCode - the 2-character province code for the selected city/town. + * + * Example: for Toronto, Ontario, the following parameters would be used + * + * siteCode: 's0000458', + * provCode: 'ON' + * + * To determine the siteCode and provCode values for a Canadian city/town, look at the Environment Canada document + * at https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv (or site_list_fr.csv). There you will find a table + * with locations you can search under column B (English Names), with the corresponding siteCode under + * column A (Codes) and provCode under column C (Province). + * + * Original by Kevin Godin + * + * License to use Environment Canada (EC) data is detailed here: + * https://eccc-msc.github.io/open-data/licence/readme_en/ + * + */ + +WeatherProvider.register("envcanada", { + // Set the name of the provider for debugging and alerting purposes (eg. provide eye-catcher) + providerName: "Environment Canada", + + // Set the default config properties that is specific to this provider + defaults: { + useCorsProxy: true, + siteCode: "s1234567", + provCode: "ON" + }, + + // + // Set config values (equates to weather module config values). Also set values pertaining to caching of + // Today's temperature forecast (for use in the Forecast functions below) + // + setConfig: function (config) { + this.config = config; + + this.todayTempCacheMin = 0; + this.todayTempCacheMax = 0; + this.todayCached = false; + this.cacheCurrentTemp = 999; + }, + + // + // Called when the weather provider is started + // + start: function () { + Log.info(`Weather provider: ${this.providerName} started.`); + this.setFetchedLocation(this.config.location); + }, + + // + // Override the fetchCurrentWeather method to query EC and construct a Current weather object + // + fetchCurrentWeather() { + this.fetchData(this.getUrl(), "xml") + .then((data) => { + if (!data) { + // Did not receive usable new data. + return; + } + const currentWeather = this.generateWeatherObjectFromCurrentWeather(data); + + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load EnvCanada site data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // + // Override the fetchWeatherForecast method to query EC and construct Forecast weather objects + // + fetchWeatherForecast() { + this.fetchData(this.getUrl(), "xml") + .then((data) => { + if (!data) { + // Did not receive usable new data. + return; + } + const forecastWeather = this.generateWeatherObjectsFromForecast(data); + + this.setWeatherForecast(forecastWeather); + }) + .catch(function (request) { + Log.error("Could not load EnvCanada forecast data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // + // Override the fetchWeatherHourly method to query EC and construct Forecast weather objects + // + fetchWeatherHourly() { + this.fetchData(this.getUrl(), "xml") + .then((data) => { + if (!data) { + // Did not receive usable new data. + return; + } + const hourlyWeather = this.generateWeatherObjectsFromHourly(data); + + this.setWeatherHourly(hourlyWeather); + }) + .catch(function (request) { + Log.error("Could not load EnvCanada hourly data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + ////////////////////////////////////////////////////////////////////////////////// + // + // Environment Canada methods - not part of the standard Provider methods + // + ////////////////////////////////////////////////////////////////////////////////// + + // + // Build the EC URL based on the Site Code and Province Code specified in the config params. Note that the + // URL defaults to the English version simply because there is no language dependency in the data + // being accessed. This is only pertinent when using the EC data elements that contain a textual forecast. + // + getUrl() { + return `https://dd.weather.gc.ca/citypage_weather/xml/${this.config.provCode}/${this.config.siteCode}_e.xml`; + }, + + // + // Generate a WeatherObject based on current EC weather conditions + // + + generateWeatherObjectFromCurrentWeather(ECdoc) { + const currentWeather = new WeatherObject(); + + // There are instances where EC will update weather data and current temperature will not be + // provided. While this is a defect in the EC systems, we need to accommodate to avoid a current temp + // of NaN being displayed. Therefore... whenever we get a valid current temp from EC, we will cache + // the value. Whenever EC data is missing current temp, we will provide the cached value + // instead. This is reasonable since the cached value will typically be accurate within the previous + // hour. The only time this does not work as expected is when MM is restarted and the first query to + // EC finds no current temp. In this scenario, MM will end up displaying a current temp of null; + + if (ECdoc.querySelector("siteData currentConditions temperature").textContent) { + currentWeather.temperature = ECdoc.querySelector("siteData currentConditions temperature").textContent; + this.cacheCurrentTemp = currentWeather.temperature; + } else { + currentWeather.temperature = this.cacheCurrentTemp; + } + + currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent); + + currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent; + + currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent; + + // Ensure showPrecipitationAmount is forced to false. EC does not really provide POP for current day + // and this feature for the weather module (current only) is sort of broken in that it wants + // to say POP but will display precip as an accumulated amount vs. a percentage. + + this.config.showPrecipitationAmount = false; + + // + // If the module config wants to showFeelsLike... default to the current temperature. + // Check for EC wind chill and humidex values and overwrite the feelsLikeTemp value. + // This assumes that the EC current conditions will never contain both a wind chill + // and humidex temperature. + // + + if (this.config.showFeelsLike) { + currentWeather.feelsLikeTemp = currentWeather.temperature; + + if (ECdoc.querySelector("siteData currentConditions windChill")) { + currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions windChill").textContent; + } + + if (ECdoc.querySelector("siteData currentConditions humidex")) { + currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions humidex").textContent; + } + } + + // + // Need to map EC weather icon to MM weatherType values + // + + currentWeather.weatherType = this.convertWeatherType(ECdoc.querySelector("siteData currentConditions iconCode").textContent); + + // + // Capture the sunrise and sunset values from EC data + // + + const sunList = ECdoc.querySelectorAll("siteData riseSet dateTime"); + + currentWeather.sunrise = moment(sunList[1].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); + currentWeather.sunset = moment(sunList[3].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); + + return currentWeather; + }, + + // + // Generate an array of WeatherObjects based on EC weather forecast + // + + generateWeatherObjectsFromForecast(ECdoc) { + // Declare an array to hold each day's forecast object + + const days = []; + + const weather = new WeatherObject(); + + const foreBaseDates = ECdoc.querySelectorAll("siteData forecastGroup dateTime"); + const baseDate = foreBaseDates[1].querySelector("timeStamp").textContent; + + weather.date = moment(baseDate, "YYYYMMDDhhmmss"); + + const foreGroup = ECdoc.querySelectorAll("siteData forecastGroup forecast"); + + weather.precipitationAmount = null; + + // + // The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing + // 2 elements. the first element for a day details the Today (daytime) forecast while the second + // element details the Tonight (nightime) forecast. Element 0 is always for the current day. + // + // However... the forecast is somewhat 'rolling'. + // + // If the EC forecast is queried in the morning, then Element 0 will contain Current + // Today and Element 1 will contain Current Tonight. From there, the next 5 days of forecast will be + // contained in Elements 2/3, 4/5, 6/7, 8/9, and 10/11. This module will create a 6-day forecast using + // all of these Elements. + // + // But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled + // off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in + // Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Elelement 11 will contain a forecast for a 6th day, + // but only for the Today portion (not Tonight). This module will create a 6-day forecast using + // Elements 0 to 11, and will ignore the additional Todat forecast in Element 11. + // + // We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight. + // This is required to understand how Min and Max temperature will be determined, and to understand + // where the next day's (aka Tomorrow's) forecast is located in the forecast array. + // + + let nextDay = 0; + let lastDay = 0; + const currentTemp = ECdoc.querySelector("siteData currentConditions temperature").textContent; + + // + // If the first Element is Current Today, look at Current Today and Current Tonight for the current day. + // + + if (foreGroup[0].querySelector("period[textForecastName='Today']")) { + this.todaytempCacheMin = 0; + this.todaytempCacheMax = 0; + this.todayCached = true; + + this.setMinMaxTemps(weather, foreGroup, 0, true, currentTemp); + + this.setPrecipitation(weather, foreGroup, 0); + + // + // Set the Element number that will reflect where the next day's forecast is located. Also set + // the Element number where the end of the forecast will be. This is important because of the + // rolling nature of the EC forecast. In the current scenario (Today and Tonight are present + // in elements 0 and 11, we know that we will have 6 full days of forecasts and we will use + // them. We will set lastDay such that we iterate through all 12 elements of the forecast. + // + + nextDay = 2; + lastDay = 12; + } + + // + // If the first Element is Current Tonight, look at Tonight only for the current day. + // + if (foreGroup[0].querySelector("period[textForecastName='Tonight']")) { + this.setMinMaxTemps(weather, foreGroup, 0, false, currentTemp); + + this.setPrecipitation(weather, foreGroup, 0); + + // + // Set the Element number that will reflect where the next day's forecast is located. Also set + // the Element number where the end of the forecast will be. This is important because of the + // rolling nature of the EC forecast. In the current scenario (only Current Tonight is present + // in Element 0, we know that we will have 6 full days of forecasts PLUS a half-day and + // forecast in the final element. Because we will only use full day forecasts, we set the + // lastDay number to ensure we ignore that final half-day (in forecast Element 11). + // + + nextDay = 1; + lastDay = 11; + } + + // + // Need to map EC weather icon to MM weatherType values. Always pick the first Element's icon to + // reflect either Today or Tonight depending on what the forecast is showing in Element 0. + // + + weather.weatherType = this.convertWeatherType(foreGroup[0].querySelector("abbreviatedForecast iconCode").textContent); + + // Push the weather object into the forecast array. + + days.push(weather); + + // + // Now do the rest of the forecast starting at nextDay. We will process each day using 2 EC + // forecast Elements. This will address the fact that the EC forecast always includes Today and + // Tonight for each day. This is why we iterate through the forecast by a a count of 2, with each + // iteration looking at the current Element and the next Element. + // + + let lastDate = moment(baseDate, "YYYYMMDDhhmmss"); + + for (let stepDay = nextDay; stepDay < lastDay; stepDay += 2) { + let weather = new WeatherObject(); + + // Add 1 to the date to reflect the current forecast day we are building + + lastDate = lastDate.add(1, "day"); + weather.date = moment(lastDate); + + // Capture the temperatures for the current Element and the next Element in order to set + // the Min and Max temperatures for the forecast + + this.setMinMaxTemps(weather, foreGroup, stepDay, true, currentTemp); + + weather.precipitationAmount = null; + + this.setPrecipitation(weather, foreGroup, stepDay); + + // + // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. + // + + weather.weatherType = this.convertWeatherType(foreGroup[stepDay].querySelector("abbreviatedForecast iconCode").textContent); + + // Push the weather object into the forecast array. + + days.push(weather); + } + + return days; + }, + + // + // Generate an array of WeatherObjects based on EC hourly weather forecast + // + + generateWeatherObjectsFromHourly(ECdoc) { + // Declare an array to hold each hour's forecast object + + const hours = []; + + // Get local timezone UTC offset so that each hourly time can be calculated properly + + const baseHours = ECdoc.querySelectorAll("siteData hourlyForecastGroup dateTime"); + const hourOffset = baseHours[1].getAttribute("UTCOffset"); + + // + // The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding + // the forecast for the next 'on the hour' timeslot. This means the array is a rolling 24 hours. + // + + const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast"); + + for (let stepHour = 0; stepHour < 24; stepHour += 1) { + const weather = new WeatherObject(); + + // Determine local time by applying UTC offset to the forecast timestamp + + const foreTime = moment(hourGroup[stepHour].getAttribute("dateTimeUTC"), "YYYYMMDDhhmmss"); + const currTime = foreTime.add(hourOffset, "hours"); + weather.date = moment(currTime); + + // Capture the temperature + + weather.temperature = hourGroup[stepHour].querySelector("temperature").textContent; + + // Capture Likelihood of Precipitation (LOP) and unit-of-measure values + + const precipLOP = hourGroup[stepHour].querySelector("lop").textContent * 1.0; + + if (precipLOP > 0) { + weather.precipitationProbability = precipLOP; + } + + // + // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. + // + + weather.weatherType = this.convertWeatherType(hourGroup[stepHour].querySelector("iconCode").textContent); + + // Push the weather object into the forecast array. + + hours.push(weather); + } + + return hours; + }, + // + // Determine Min and Max temp based on a supplied Forecast Element index and a boolen that denotes if + // the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only + // + + setMinMaxTemps(weather, foreGroup, today, fullDay, currentTemp) { + const todayTemp = foreGroup[today].querySelector("temperatures temperature").textContent; + + const todayClass = foreGroup[today].querySelector("temperatures temperature").getAttribute("class"); + + // + // The following logic is largely aimed at accommodating the Current day's forecast whereby we + // can have either Current Today+Current Tonight or only Current Tonight. + // + // If fullDay is false, then we only have Tonight for the current day's forecast - meaning we have + // lost a min or max temp value for the day. Therefore, we will see if we were able to cache the the + // Today forecast for the current day. If we have, we will use them. If we do not have the cached values, + // it means that MM or the Computer has been restarted since the time EC rolled off Today from the + // forecast. In this scenario, we will simply default to the Current Conditions temperature and then + // check the Tonight temperature. + // + + if (fullDay === false) { + if (this.todayCached === true) { + weather.minTemperature = this.todayTempCacheMin; + weather.maxTemperature = this.todayTempCacheMax; + } else { + weather.minTemperature = currentTemp; + weather.maxTemperature = weather.minTemperature; + } + } + + // + // We will check to see if the current Element's temperature is Low or High and set weather values + // accordingly. We will also check the condition where fullDay is true *and* we are looking at forecast + // element 0. This is a special case where we will cache temperature values so that we have them later + // in the current day when the Current Today element rolls off and we have Current Tonight only. + // + + if (todayClass === "low") { + weather.minTemperature = todayTemp; + if (today === 0 && fullDay === true) { + this.todayTempCacheMin = weather.minTemperature; + } + } + + if (todayClass === "high") { + weather.maxTemperature = todayTemp; + if (today === 0 && fullDay === true) { + this.todayTempCacheMax = weather.maxTemperature; + } + } + + const nextTemp = foreGroup[today + 1].querySelector("temperatures temperature").textContent; + + const nextClass = foreGroup[today + 1].querySelector("temperatures temperature").getAttribute("class"); + + if (fullDay === true) { + if (nextClass === "low") { + weather.minTemperature = nextTemp; + } + + if (nextClass === "high") { + weather.maxTemperature = nextTemp; + } + } + }, + + // + // Check for a Precipitation forecast. EC can provide a forecast in 2 ways: either an accumulation figure + // or a POP percentage. If there is a POP, then that is what the module will show. If there is an accumulation, + // then it will be displayed ONLY if no POP is present. + // + // POP Logic: By default, we want to show the POP for 'daytime' since we are presuming that is what + // people are more interested in seeing. While EC provides a separate POP for daytime and nightime portions + // of each day, the weather module does not really allow for that view of a daily forecast. There we will + // ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show + // the nightime forecast after a certain point in the afternoon. As such, we will be showing the nightime POP + // (if one exists) in that specific scenario. + // + // Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what + // people are interested in seeing. While EC provides a separate accumulation for daytime and nightime portions + // of each day, the weather module does not really allow for that view of a daily forecast. There we will + // ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show + // the nightime forecast after a certain point in that specific scenario. + // + + setPrecipitation(weather, foreGroup, today) { + if (foreGroup[today].querySelector("precipitation accumulation")) { + weather.precipitationAmount = foreGroup[today].querySelector("precipitation accumulation amount").textContent * 1.0; + weather.precipitationUnits = foreGroup[today].querySelector("precipitation accumulation amount").getAttribute("units"); + } + + // Check Today element for POP + const precipPOP = foreGroup[today].querySelector("abbreviatedForecast pop").textContent * 1.0; + if (precipPOP > 0) { + weather.precipitationProbability = precipPOP; + } + }, + + // + // Convert the icons to a more usable name. + // + convertWeatherType(weatherType) { + const weatherTypes = { + "00": "day-sunny", + "01": "day-sunny", + "02": "day-sunny-overcast", + "03": "day-cloudy", + "04": "day-cloudy", + "05": "day-cloudy", + "06": "day-sprinkle", + "07": "day-showers", + "08": "day-snow", + "09": "day-thunderstorm", + 10: "cloud", + 11: "showers", + 12: "rain", + 13: "rain", + 14: "sleet", + 15: "sleet", + 16: "snow", + 17: "snow", + 18: "snow", + 19: "thunderstorm", + 20: "cloudy", + 21: "cloudy", + 22: "day-cloudy", + 23: "day-haze", + 24: "fog", + 25: "snow-wind", + 26: "sleet", + 27: "sleet", + 28: "rain", + 29: "na", + 30: "night-clear", + 31: "night-clear", + 32: "night-partly-cloudy", + 33: "night-alt-cloudy", + 34: "night-alt-cloudy", + 35: "night-partly-cloudy", + 36: "night-alt-showers", + 37: "night-rain-mix", + 38: "night-alt-snow", + 39: "night-thunderstorm", + 40: "snow-wind", + 41: "tornado", + 42: "tornado", + 43: "windy", + 44: "smoke", + 45: "sandstorm", + 46: "thunderstorm", + 47: "thunderstorm", + 48: "tornado" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/openmeteo.js b/mm/mounts/modules/default/weather/providers/openmeteo.js new file mode 100644 index 0000000..79d7d1f --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/openmeteo.js @@ -0,0 +1,548 @@ +/* global WeatherProvider, WeatherObject */ + +/* MagicMirror² + * Module: Weather + * Provider: Open-Meteo + * + * By Andrés Vanegas + * MIT Licensed + * + * This class is a provider for Open-Meteo, based on Andrew Pometti's class + * for Weatherbit. + */ +// https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api +const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client"; +const OPEN_METEO_BASE = "https://api.open-meteo.com/v1"; + +WeatherProvider.register("openmeteo", { + // Set the name of the provider. + // Not strictly required, but helps for debugging. + providerName: "Open-Meteo", + + // Set the default config properties that is specific to this provider + defaults: { + apiBase: OPEN_METEO_BASE, + lat: 0, + lon: 0, + pastDays: 0, + type: "current" + }, + + // https://open-meteo.com/en/docs + hourlyParams: [ + // Air temperature at 2 meters above ground + "temperature_2m", + // Relative humidity at 2 meters above ground + "relativehumidity_2m", + // Dew point temperature at 2 meters above ground + "dewpoint_2m", + // Apparent temperature is the perceived feels-like temperature combining wind chill factor, relative humidity and solar radiation + "apparent_temperature", + // Atmospheric air pressure reduced to mean sea level (msl) or pressure at surface. Typically pressure on mean sea level is used in meteorology. Surface pressure gets lower with increasing elevation. + "pressure_msl", + "surface_pressure", + // Total cloud cover as an area fraction + "cloudcover", + // Low level clouds and fog up to 3 km altitude + "cloudcover_low", + // Mid level clouds from 3 to 8 km altitude + "cloudcover_mid", + // High level clouds from 8 km altitude + "cloudcover_high", + // Wind speed at 10, 80, 120 or 180 meters above ground. Wind speed on 10 meters is the standard level. + "windspeed_10m", + "windspeed_80m", + "windspeed_120m", + "windspeed_180m", + // Wind direction at 10, 80, 120 or 180 meters above ground + "winddirection_10m", + "winddirection_80m", + "winddirection_120m", + "winddirection_180m", + // Gusts at 10 meters above ground as a maximum of the preceding hour + "windgusts_10m", + // Shortwave solar radiation as average of the preceding hour. This is equal to the total global horizontal irradiation + "shortwave_radiation", + // Direct solar radiation as average of the preceding hour on the horizontal plane and the normal plane (perpendicular to the sun) + "direct_radiation", + "direct_normal_irradiance", + // Diffuse solar radiation as average of the preceding hour + "diffuse_radiation", + // Vapor Pressure Deificit (VPD) in kilopascal (kPa). For high VPD (>1.6), water transpiration of plants increases. For low VPD (<0.4), transpiration decreases + "vapor_pressure_deficit", + // Evapotranspration from land surface and plants that weather models assumes for this location. Available soil water is considered. 1 mm evapotranspiration per hour equals 1 liter of water per spare meter. + "evapotranspiration", + // ET₀ Reference Evapotranspiration of a well watered grass field. Based on FAO-56 Penman-Monteith equations ET₀ is calculated from temperature, wind speed, humidity and solar radiation. Unlimited soil water is assumed. ET₀ is commonly used to estimate the required irrigation for plants. + "et0_fao_evapotranspiration", + // Total precipitation (rain, showers, snow) sum of the preceding hour + "precipitation", + // Precipitation Probability + "precipitation_probability", + // UV index + "uv_index", + // Snowfall amount of the preceding hour in centimeters. For the water equivalent in millimeter, divide by 7. E.g. 7 cm snow = 10 mm precipitation water equivalent + "snowfall", + // Rain from large scale weather systems of the preceding hour in millimeter + "rain", + // Showers from convective precipitation in millimeters from the preceding hour + "showers", + // Weather condition as a numeric code. Follow WMO weather interpretation codes. + "weathercode", + // Snow depth on the ground + "snow_depth", + // Altitude above sea level of the 0°C level + "freezinglevel_height", + // Temperature in the soil at 0, 6, 18 and 54 cm depths. 0 cm is the surface temperature on land or water surface temperature on water. + "soil_temperature_0cm", + "soil_temperature_6cm", + "soil_temperature_18cm", + "soil_temperature_54cm", + // Average soil water content as volumetric mixing ratio at 0-1, 1-3, 3-9, 9-27 and 27-81 cm depths. + "soil_moisture_0_1cm", + "soil_moisture_1_3cm", + "soil_moisture_3_9cm", + "soil_moisture_9_27cm", + "soil_moisture_27_81cm" + ], + + dailyParams: [ + // Maximum and minimum daily air temperature at 2 meters above ground + "temperature_2m_max", + "temperature_2m_min", + // Maximum and minimum daily apparent temperature + "apparent_temperature_min", + "apparent_temperature_max", + // Sum of daily precipitation (including rain, showers and snowfall) + "precipitation_sum", + // Sum of daily rain + "rain_sum", + // Sum of daily showers + "showers_sum", + // Sum of daily snowfall + "snowfall_sum", + // The number of hours with rain + "precipitation_hours", + // The most severe weather condition on a given day + "weathercode", + // Sun rise and set times + "sunrise", + "sunset", + // Maximum wind speed and gusts on a day + "windspeed_10m_max", + "windgusts_10m_max", + // Dominant wind direction + "winddirection_10m_dominant", + // The sum of solar radiation on a given day in Megajoules + "shortwave_radiation_sum", + //UV Index + "uv_index_max", + // Daily sum of ET₀ Reference Evapotranspiration of a well watered grass field + "et0_fao_evapotranspiration" + ], + + fetchedLocation: function () { + return this.fetchedLocationName || ""; + }, + + fetchCurrentWeather() { + this.fetchData(this.getUrl()) + .then((data) => this.parseWeatherApiResponse(data)) + .then((parsedData) => { + if (!parsedData) { + // No usable data? + return; + } + + const currentWeather = this.generateWeatherDayFromCurrentWeather(parsedData); + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + fetchWeatherForecast() { + this.fetchData(this.getUrl()) + .then((data) => this.parseWeatherApiResponse(data)) + .then((parsedData) => { + if (!parsedData) { + // No usable data? + return; + } + + const dailyForecast = this.generateWeatherObjectsFromForecast(parsedData); + this.setWeatherForecast(dailyForecast); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + fetchWeatherHourly() { + this.fetchData(this.getUrl()) + .then((data) => this.parseWeatherApiResponse(data)) + .then((parsedData) => { + if (!parsedData) { + // No usable data? + return; + } + + const hourlyForecast = this.generateWeatherObjectsFromHourly(parsedData); + this.setWeatherHourly(hourlyForecast); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + /** + * Overrides method for setting config to check if endpoint is correct for hourly + * @param {object} config The configuration object + */ + setConfig(config) { + this.config = { + lang: config.lang ?? "en", + ...this.defaults, + ...config + }; + + // Set properly maxNumberOfDays and max Entries properties according to config and value ranges allowed in the documentation + const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0; + if (this.config.hasOwnProperty("maxNumberOfDays") && !isNaN(parseFloat(this.config.maxNumberOfDays))) { + const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0; + this.config.maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit)); + this.config.maxNumberOfDays = Math.ceil(this.config.maxEntries / Math.max(1, daysFactor)); + } + this.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit)); + + if (!this.config.type) { + Log.error("type not configured and could not resolve it"); + } + + this.fetchLocation(); + }, + + // Generate valid query params to perform the request + getQueryParameters() { + let params = { + latitude: this.config.lat, + longitude: this.config.lon, + timeformat: "unixtime", + timezone: "auto", + past_days: this.config.pastDays ?? 0, + daily: this.dailyParams, + hourly: this.hourlyParams, + // Fixed units as metric + temperature_unit: "celsius", + windspeed_unit: "ms", + precipitation_unit: "mm" + }; + + const startDate = moment().startOf("day"); + const endDate = moment(startDate) + .add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), "days") + .endOf("day"); + + params["start_date"] = startDate.format("YYYY-MM-DD"); + + switch (this.config.type) { + case "hourly": + case "daily": + case "forecast": + params["end_date"] = endDate.format("YYYY-MM-DD"); + break; + case "current": + params["current_weather"] = true; + params["end_date"] = params["start_date"]; + break; + default: + // Failsafe + return ""; + } + + return Object.keys(params) + .filter((key) => (params[key] ? true : false)) + .map((key) => { + switch (key) { + case "hourly": + case "daily": + return `${encodeURIComponent(key)}=${params[key].join(",")}`; + default: + return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`; + } + }) + .join("&"); + }, + + // Create a URL from the config and base URL. + getUrl() { + return `${this.config.apiBase}/forecast?${this.getQueryParameters()}`; + }, + + // Transpose hourly and daily data matrices + transposeDataMatrix(data) { + return data.time.map((_, index) => + Object.keys(data).reduce((row, key) => { + return { + ...row, + // Parse time values as momentjs instances + [key]: ["time", "sunrise", "sunset"].includes(key) ? moment.unix(data[key][index]) : data[key][index] + }; + }, {}) + ); + }, + + // Sanitize and validate API response + parseWeatherApiResponse(data) { + const validByType = { + current: data.current_weather && data.current_weather.time, + hourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0, + daily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0 + }; + // backwards compatibility + const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type; + + if (!validByType[type]) return; + + switch (type) { + case "current": + if (!validByType.daily && !validByType.hourly) { + return; + } + break; + case "hourly": + case "daily": + break; + default: + return; + } + + for (const key of ["hourly", "daily"]) { + if (typeof data[key] === "object") { + data[key] = this.transposeDataMatrix(data[key]); + } + } + + if (data.current_weather) { + data.current_weather.time = moment.unix(data.current_weather.time); + } + + return data; + }, + + // Reverse geocoding from latitude and longitude provided + fetchLocation() { + this.fetchData(`${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang}`) + .then((data) => { + if (!data || !data.city) { + // No usable data? + return; + } + this.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`; + }) + .catch((request) => { + Log.error("Could not load data ... ", request); + }); + }, + + // Implement WeatherDay generator. + generateWeatherDayFromCurrentWeather(weather) { + /** + * Since some units comes from API response "splitted" into daily, hourly and current_weather + * every time you request it, you have to ensure to get the data from the right place every time. + * For the current weather case, the response have the following structure (after transposing): + * ``` + * { + * current_weather: { ... }, + * hourly: [ + * 0: {... }, + * 1: {... }, + * ... + * ], + * daily: [ + * {... }, + * ] + * } + * ``` + * Some data should be returned from `hourly` array data when the index matches the current hour, + * some data from the first and only one object received in `daily` array and some from the + * `current_weather` object. + */ + const h = moment().hour(); + const currentWeather = new WeatherObject(); + + currentWeather.date = weather.current_weather.time; + currentWeather.windSpeed = weather.current_weather.windspeed; + currentWeather.windFromDirection = weather.current_weather.winddirection; + currentWeather.sunrise = weather.daily[0].sunrise; + currentWeather.sunset = weather.daily[0].sunset; + currentWeather.temperature = parseFloat(weather.current_weather.temperature); + currentWeather.minTemperature = parseFloat(weather.daily[0].temperature_2m_min); + currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max); + currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime()); + currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m); + currentWeather.rain = parseFloat(weather.hourly[h].rain); + currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10); + currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation); + currentWeather.precipitationProbability = parseFloat(weather.hourly[h].precipitation_probability); + currentWeather.uv_index = parseFloat(weather.hourly[h].uv_index); + + return currentWeather; + }, + + // Implement WeatherForecast generator. + generateWeatherObjectsFromForecast(weathers) { + const days = []; + + weathers.daily.forEach((weather, i) => { + const currentWeather = new WeatherObject(); + + currentWeather.date = weather.time; + currentWeather.windSpeed = weather.windspeed_10m_max; + currentWeather.windFromDirection = weather.winddirection_10m_dominant; + currentWeather.sunrise = weather.sunrise; + currentWeather.sunset = weather.sunset; + currentWeather.temperature = parseFloat((weather.apparent_temperature_max + weather.apparent_temperature_min) / 2); + currentWeather.minTemperature = parseFloat(weather.apparent_temperature_min); + currentWeather.maxTemperature = parseFloat(weather.apparent_temperature_max); + currentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime()); + currentWeather.rain = parseFloat(weather.rain_sum); + currentWeather.snow = parseFloat(weather.snowfall_sum * 10); + currentWeather.precipitationAmount = parseFloat(weather.precipitation_sum); + currentWeather.precipitationProbability = parseFloat(weather.precipitation_probability); + currentWeather.uv_index = parseFloat(weather.uv_index_max); + + days.push(currentWeather); + }); + + return days; + }, + + // Implement WeatherHourly generator. + generateWeatherObjectsFromHourly(weathers) { + const hours = []; + const now = moment(); + + weathers.hourly.forEach((weather, i) => { + if ((hours.length === 0 && weather.time.hour() <= now.hour()) || hours.length >= this.config.maxEntries) { + return; + } + + const currentWeather = new WeatherObject(); + const h = Math.ceil((i + 1) / 24) - 1; + + currentWeather.date = weather.time; + currentWeather.windSpeed = weather.windspeed_10m; + currentWeather.windFromDirection = weather.winddirection_10m; + currentWeather.sunrise = weathers.daily[h].sunrise; + currentWeather.sunset = weathers.daily[h].sunset; + currentWeather.temperature = parseFloat(weather.apparent_temperature); + currentWeather.minTemperature = parseFloat(weathers.daily[h].apparent_temperature_min); + currentWeather.maxTemperature = parseFloat(weathers.daily[h].apparent_temperature_max); + currentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime()); + currentWeather.humidity = parseFloat(weather.relativehumidity_2m); + currentWeather.rain = parseFloat(weather.rain); + currentWeather.snow = parseFloat(weather.snowfall * 10); + currentWeather.precipitationAmount = parseFloat(weather.precipitation); + currentWeather.precipitationProbability = parseFloat(weather.precipitation_probability); + currentWeather.uv_index = parseFloat(weather.uv_index); + + hours.push(currentWeather); + }); + + return hours; + }, + + // Map icons from Dark Sky to our icons. + convertWeatherType(weathercode, isDayTime) { + const weatherConditions = { + 0: "clear", + 1: "mainly-clear", + 2: "partly-cloudy", + 3: "overcast", + 45: "fog", + 48: "depositing-rime-fog", + 51: "drizzle-light-intensity", + 53: "drizzle-moderate-intensity", + 55: "drizzle-dense-intensity", + 56: "freezing-drizzle-light-intensity", + 57: "freezing-drizzle-dense-intensity", + 61: "rain-slight-intensity", + 63: "rain-moderate-intensity", + 65: "rain-heavy-intensity", + 66: "freezing-rain-light-heavy-intensity", + 67: "freezing-rain-heavy-intensity", + 71: "snow-fall-slight-intensity", + 73: "snow-fall-moderate-intensity", + 75: "snow-fall-heavy-intensity", + 77: "snow-grains", + 80: "rain-showers-slight", + 81: "rain-showers-moderate", + 82: "rain-showers-violent", + 85: "snow-showers-slight", + 86: "snow-showers-heavy", + 95: "thunderstorm", + 96: "thunderstorm-slight-hail", + 99: "thunderstorm-heavy-hail" + }; + + if (!Object.keys(weatherConditions).includes(`${weathercode}`)) return null; + + switch (weatherConditions[`${weathercode}`]) { + case "clear": + return isDayTime ? "day-sunny" : "night-clear"; + case "mainly-clear": + case "partly-cloudy": + return isDayTime ? "day-cloudy" : "night-alt-cloudy"; + case "overcast": + return isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy"; + case "fog": + case "depositing-rime-fog": + return isDayTime ? "day-fog" : "night-fog"; + case "drizzle-light-intensity": + case "rain-slight-intensity": + case "rain-showers-slight": + return isDayTime ? "day-sprinkle" : "night-sprinkle"; + case "drizzle-moderate-intensity": + case "rain-moderate-intensity": + case "rain-showers-moderate": + return isDayTime ? "day-showers" : "night-showers"; + case "drizzle-dense-intensity": + case "rain-heavy-intensity": + case "rain-showers-violent": + return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; + case "freezing-rain-light-intensity": + return isDayTime ? "day-rain-mix" : "night-rain-mix"; + case "freezing-drizzle-light-intensity": + case "freezing-drizzle-dense-intensity": + return "snowflake-cold"; + case "snow-grains": + return isDayTime ? "day-sleet" : "night-sleet"; + case "snow-fall-slight-intensity": + case "snow-fall-moderate-intensity": + return isDayTime ? "day-snow-wind" : "night-snow-wind"; + case "snow-fall-heavy-intensity": + case "freezing-rain-heavy-intensity": + return isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm"; + case "snow-showers-slight": + case "snow-showers-heavy": + return isDayTime ? "day-rain-mix" : "night-rain-mix"; + case "thunderstorm": + return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; + case "thunderstorm-slight-hail": + return isDayTime ? "day-sleet" : "night-sleet"; + case "thunderstorm-heavy-hail": + return isDayTime ? "day-sleet-storm" : "night-sleet-storm"; + default: + return "na"; + } + }, + + // Define required scripts. + getScripts: function () { + return ["moment.js"]; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/openweathermap.js b/mm/mounts/modules/default/weather/providers/openweathermap.js new file mode 100644 index 0000000..5d5670d --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/openweathermap.js @@ -0,0 +1,448 @@ +/* global WeatherProvider, WeatherObject */ + +/* MagicMirror² + * Module: Weather + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + * + * This class is the blueprint for a weather provider. + */ +WeatherProvider.register("openweathermap", { + // Set the name of the provider. + // This isn't strictly necessary, since it will fallback to the provider identifier + // But for debugging (and future alerts) it would be nice to have the real name. + providerName: "OpenWeatherMap", + + // Set the default config properties that is specific to this provider + defaults: { + apiVersion: "2.5", + apiBase: "https://api.openweathermap.org/data/", + weatherEndpoint: "", // can be "onecall", "forecast" or "weather" (for current) + locationID: false, + location: false, + lat: 0, // the onecall endpoint needs lat / lon values, it doesn't support the locationId + lon: 0, + apiKey: "" + }, + + // Overwrite the fetchCurrentWeather method. + fetchCurrentWeather() { + this.fetchData(this.getUrl()) + .then((data) => { + let currentWeather; + if (this.config.weatherEndpoint === "/onecall") { + currentWeather = this.generateWeatherObjectsFromOnecall(data).current; + this.setFetchedLocation(`${data.timezone}`); + } else { + currentWeather = this.generateWeatherObjectFromCurrentWeather(data); + } + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // Overwrite the fetchWeatherForecast method. + fetchWeatherForecast() { + this.fetchData(this.getUrl()) + .then((data) => { + let forecast; + let location; + if (this.config.weatherEndpoint === "/onecall") { + forecast = this.generateWeatherObjectsFromOnecall(data).days; + location = `${data.timezone}`; + } else { + forecast = this.generateWeatherObjectsFromForecast(data.list); + location = `${data.city.name}, ${data.city.country}`; + } + this.setWeatherForecast(forecast); + this.setFetchedLocation(location); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // Overwrite the fetchWeatherHourly method. + fetchWeatherHourly() { + this.fetchData(this.getUrl()) + .then((data) => { + if (!data) { + // Did not receive usable new data. + // Maybe this needs a better check? + return; + } + + this.setFetchedLocation(`(${data.lat},${data.lon})`); + + const weatherData = this.generateWeatherObjectsFromOnecall(data); + this.setWeatherHourly(weatherData.hours); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + /** + * Overrides method for setting config to check if endpoint is correct for hourly + * @param {object} config The configuration object + */ + setConfig(config) { + this.config = config; + if (!this.config.weatherEndpoint) { + switch (this.config.type) { + case "hourly": + this.config.weatherEndpoint = "/onecall"; + break; + case "daily": + case "forecast": + this.config.weatherEndpoint = "/forecast"; + break; + case "current": + this.config.weatherEndpoint = "/weather"; + break; + default: + Log.error("weatherEndpoint not configured and could not resolve it based on type"); + } + } + }, + + /** OpenWeatherMap Specific Methods - These are not part of the default provider methods */ + /* + * Gets the complete url for the request + */ + getUrl() { + return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.getParams(); + }, + + /* + * Generate a WeatherObject based on currentWeatherInformation + */ + generateWeatherObjectFromCurrentWeather(currentWeatherData) { + const currentWeather = new WeatherObject(); + + currentWeather.date = moment.unix(currentWeatherData.dt); + currentWeather.humidity = currentWeatherData.main.humidity; + currentWeather.temperature = currentWeatherData.main.temp; + currentWeather.feelsLikeTemp = currentWeatherData.main.feels_like; + currentWeather.windSpeed = currentWeatherData.wind.speed; + currentWeather.windFromDirection = currentWeatherData.wind.deg; + currentWeather.weatherType = this.convertWeatherType(currentWeatherData.weather[0].icon); + currentWeather.sunrise = moment.unix(currentWeatherData.sys.sunrise); + currentWeather.sunset = moment.unix(currentWeatherData.sys.sunset); + + return currentWeather; + }, + + /* + * Generate WeatherObjects based on forecast information + */ + generateWeatherObjectsFromForecast(forecasts) { + if (this.config.weatherEndpoint === "/forecast") { + return this.generateForecastHourly(forecasts); + } else if (this.config.weatherEndpoint === "/forecast/daily") { + return this.generateForecastDaily(forecasts); + } + // if weatherEndpoint does not match forecast or forecast/daily, what should be returned? + return [new WeatherObject()]; + }, + + /* + * Generate WeatherObjects based on One Call forecast information + */ + generateWeatherObjectsFromOnecall(data) { + if (this.config.weatherEndpoint === "/onecall") { + return this.fetchOnecall(data); + } + // if weatherEndpoint does not match onecall, what should be returned? + return { current: new WeatherObject(), hours: [], days: [] }; + }, + + /* + * Generate forecast information for 3-hourly forecast (available for free + * subscription). + */ + generateForecastHourly(forecasts) { + // initial variable declaration + const days = []; + // variables for temperature range and rain + let minTemp = []; + let maxTemp = []; + let rain = 0; + let snow = 0; + // variable for date + let date = ""; + let weather = new WeatherObject(); + + for (const forecast of forecasts) { + if (date !== moment.unix(forecast.dt).format("YYYY-MM-DD")) { + // calculate minimum/maximum temperature, specify rain amount + weather.minTemperature = Math.min.apply(null, minTemp); + weather.maxTemperature = Math.max.apply(null, maxTemp); + weather.rain = rain; + weather.snow = snow; + weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); + // push weather information to days array + days.push(weather); + // create new weather-object + weather = new WeatherObject(); + + minTemp = []; + maxTemp = []; + rain = 0; + snow = 0; + + // set new date + date = moment.unix(forecast.dt).format("YYYY-MM-DD"); + + // specify date + weather.date = moment.unix(forecast.dt); + + // If the first value of today is later than 17:00, we have an icon at least! + weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); + } + + if (moment.unix(forecast.dt).format("H") >= 8 && moment.unix(forecast.dt).format("H") <= 17) { + weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); + } + + // the same day as before + // add values from forecast to corresponding variables + minTemp.push(forecast.main.temp_min); + maxTemp.push(forecast.main.temp_max); + + if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain["3h"])) { + rain += forecast.rain["3h"]; + } + + if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow["3h"])) { + snow += forecast.snow["3h"]; + } + } + + // last day + // calculate minimum/maximum temperature, specify rain amount + weather.minTemperature = Math.min.apply(null, minTemp); + weather.maxTemperature = Math.max.apply(null, maxTemp); + weather.rain = rain; + weather.snow = snow; + weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); + // push weather information to days array + days.push(weather); + return days.slice(1); + }, + + /* + * Generate forecast information for daily forecast (available for paid + * subscription or old apiKey). + */ + generateForecastDaily(forecasts) { + // initial variable declaration + const days = []; + + for (const forecast of forecasts) { + const weather = new WeatherObject(); + + weather.date = moment.unix(forecast.dt); + weather.minTemperature = forecast.temp.min; + weather.maxTemperature = forecast.temp.max; + weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); + weather.rain = 0; + weather.snow = 0; + + // forecast.rain not available if amount is zero + // The API always returns in millimeters + if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain)) { + weather.rain = forecast.rain; + } + + // forecast.snow not available if amount is zero + // The API always returns in millimeters + if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow)) { + weather.snow = forecast.snow; + } + + weather.precipitationAmount = weather.rain + weather.snow; + weather.precipitationProbability = forecast.pop ? forecast.pop * 100 : undefined; + + days.push(weather); + } + + return days; + }, + + /* + * Fetch One Call forecast information (available for free subscription). + * Factors in timezone offsets. + * Minutely forecasts are excluded for the moment, see getParams(). + */ + fetchOnecall(data) { + let precip = false; + + // get current weather, if requested + const current = new WeatherObject(); + if (data.hasOwnProperty("current")) { + current.date = moment.unix(data.current.dt).utcOffset(data.timezone_offset / 60); + current.windSpeed = data.current.wind_speed; + current.windFromDirection = data.current.wind_deg; + current.sunrise = moment.unix(data.current.sunrise).utcOffset(data.timezone_offset / 60); + current.sunset = moment.unix(data.current.sunset).utcOffset(data.timezone_offset / 60); + current.temperature = data.current.temp; + current.weatherType = this.convertWeatherType(data.current.weather[0].icon); + current.humidity = data.current.humidity; + if (data.current.hasOwnProperty("rain") && !isNaN(data.current["rain"]["1h"])) { + current.rain = data.current["rain"]["1h"]; + precip = true; + } + if (data.current.hasOwnProperty("snow") && !isNaN(data.current["snow"]["1h"])) { + current.snow = data.current["snow"]["1h"]; + precip = true; + } + if (precip) { + current.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0); + } + current.feelsLikeTemp = data.current.feels_like; + } + + let weather = new WeatherObject(); + + // get hourly weather, if requested + const hours = []; + if (data.hasOwnProperty("hourly")) { + for (const hour of data.hourly) { + weather.date = moment.unix(hour.dt).utcOffset(data.timezone_offset / 60); + weather.temperature = hour.temp; + weather.feelsLikeTemp = hour.feels_like; + weather.humidity = hour.humidity; + weather.windSpeed = hour.wind_speed; + weather.windFromDirection = hour.wind_deg; + weather.weatherType = this.convertWeatherType(hour.weather[0].icon); + weather.precipitationProbability = hour.pop ? hour.pop * 100 : undefined; + precip = false; + if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) { + weather.rain = hour.rain["1h"]; + precip = true; + } + if (hour.hasOwnProperty("snow") && !isNaN(hour.snow["1h"])) { + weather.snow = hour.snow["1h"]; + precip = true; + } + if (precip) { + weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); + } + + hours.push(weather); + weather = new WeatherObject(); + } + } + + // get daily weather, if requested + const days = []; + if (data.hasOwnProperty("daily")) { + for (const day of data.daily) { + weather.date = moment.unix(day.dt).utcOffset(data.timezone_offset / 60); + weather.sunrise = moment.unix(day.sunrise).utcOffset(data.timezone_offset / 60); + weather.sunset = moment.unix(day.sunset).utcOffset(data.timezone_offset / 60); + weather.minTemperature = day.temp.min; + weather.maxTemperature = day.temp.max; + weather.humidity = day.humidity; + weather.windSpeed = day.wind_speed; + weather.windFromDirection = day.wind_deg; + weather.weatherType = this.convertWeatherType(day.weather[0].icon); + weather.precipitationProbability = day.pop ? day.pop * 100 : undefined; + precip = false; + if (!isNaN(day.rain)) { + weather.rain = day.rain; + precip = true; + } + if (!isNaN(day.snow)) { + weather.snow = day.snow; + precip = true; + } + if (precip) { + weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); + } + + days.push(weather); + weather = new WeatherObject(); + } + } + + return { current: current, hours: hours, days: days }; + }, + + /* + * Convert the OpenWeatherMap icons to a more usable name. + */ + convertWeatherType(weatherType) { + const weatherTypes = { + "01d": "day-sunny", + "02d": "day-cloudy", + "03d": "cloudy", + "04d": "cloudy-windy", + "09d": "showers", + "10d": "rain", + "11d": "thunderstorm", + "13d": "snow", + "50d": "fog", + "01n": "night-clear", + "02n": "night-cloudy", + "03n": "night-cloudy", + "04n": "night-cloudy", + "09n": "night-showers", + "10n": "night-rain", + "11n": "night-thunderstorm", + "13n": "night-snow", + "50n": "night-alt-cloudy-windy" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + }, + + /* getParams(compliments) + * Generates an url with api parameters based on the config. + * + * return String - URL params. + */ + getParams() { + let params = "?"; + if (this.config.weatherEndpoint === "/onecall") { + params += `lat=${this.config.lat}`; + params += `&lon=${this.config.lon}`; + if (this.config.type === "current") { + params += "&exclude=minutely,hourly,daily"; + } else if (this.config.type === "hourly") { + params += "&exclude=current,minutely,daily"; + } else if (this.config.type === "daily" || this.config.type === "forecast") { + params += "&exclude=current,minutely,hourly"; + } else { + params += "&exclude=minutely"; + } + } else if (this.config.lat && this.config.lon) { + params += `lat=${this.config.lat}&lon=${this.config.lon}`; + } else if (this.config.locationID) { + params += `id=${this.config.locationID}`; + } else if (this.config.location) { + params += `q=${this.config.location}`; + } else if (this.firstEvent && this.firstEvent.geo) { + params += `lat=${this.firstEvent.geo.lat}&lon=${this.firstEvent.geo.lon}`; + } else if (this.firstEvent && this.firstEvent.location) { + params += `q=${this.firstEvent.location}`; + } else { + // TODO hide doesnt exist! + this.hide(this.config.animationSpeed, { lockString: this.identifier }); + return; + } + + params += "&units=metric"; // WeatherProviders should use metric internally and use the units only for when displaying data + params += `&lang=${this.config.lang}`; + params += `&APPID=${this.config.apiKey}`; + + return params; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/pirateweather.js b/mm/mounts/modules/default/weather/providers/pirateweather.js new file mode 100644 index 0000000..1bb9561 --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/pirateweather.js @@ -0,0 +1,133 @@ +/* global WeatherProvider, WeatherObject */ + +/* MagicMirror² + * Module: Weather + * Provider: Pirate Weather + * + * Written by Nicholas Hubbard https://github.com/nhubbard for formerly Dark Sky Provider + * Modified by Karsten Hassel for Pirate Weather + * MIT Licensed + * + * This class is a provider for Pirate Weather, it is a replacement for Dark Sky (same api). + */ +WeatherProvider.register("pirateweather", { + // Set the name of the provider. + // Not strictly required, but helps for debugging. + providerName: "pirateweather", + + // Set the default config properties that is specific to this provider + defaults: { + useCorsProxy: true, + apiBase: "https://api.pirateweather.net", + weatherEndpoint: "/forecast", + apiKey: "", + lat: 0, + lon: 0 + }, + + fetchCurrentWeather() { + this.fetchData(this.getUrl()) + .then((data) => { + if (!data || !data.currently || typeof data.currently.temperature === "undefined") { + // No usable data? + return; + } + + const currentWeather = this.generateWeatherDayFromCurrentWeather(data); + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + fetchWeatherForecast() { + this.fetchData(this.getUrl()) + .then((data) => { + if (!data || !data.daily || !data.daily.data.length) { + // No usable data? + return; + } + + const forecast = this.generateWeatherObjectsFromForecast(data.daily.data); + this.setWeatherForecast(forecast); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // Create a URL from the config and base URL. + getUrl() { + return `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${this.config.lang}`; + }, + + // Implement WeatherDay generator. + generateWeatherDayFromCurrentWeather(currentWeatherData) { + const currentWeather = new WeatherObject(); + + currentWeather.date = moment(); + currentWeather.humidity = parseFloat(currentWeatherData.currently.humidity); + currentWeather.temperature = parseFloat(currentWeatherData.currently.temperature); + currentWeather.windSpeed = parseFloat(currentWeatherData.currently.windSpeed); + currentWeather.windFromDirection = currentWeatherData.currently.windBearing; + currentWeather.weatherType = this.convertWeatherType(currentWeatherData.currently.icon); + currentWeather.sunrise = moment.unix(currentWeatherData.daily.data[0].sunriseTime); + currentWeather.sunset = moment.unix(currentWeatherData.daily.data[0].sunsetTime); + + return currentWeather; + }, + + generateWeatherObjectsFromForecast(forecasts) { + const days = []; + + for (const forecast of forecasts) { + const weather = new WeatherObject(); + + weather.date = moment.unix(forecast.time); + weather.minTemperature = forecast.temperatureMin; + weather.maxTemperature = forecast.temperatureMax; + weather.weatherType = this.convertWeatherType(forecast.icon); + weather.snow = 0; + weather.rain = 0; + + let precip = 0; + if (forecast.hasOwnProperty("precipAccumulation")) { + precip = forecast.precipAccumulation * 10; + } + + weather.precipitationAmount = precip; + if (forecast.hasOwnProperty("precipType")) { + if (forecast.precipType === "snow") { + weather.snow = precip; + } else { + weather.rain = precip; + } + } + + days.push(weather); + } + + return days; + }, + + // Map icons from Pirate Weather to our icons. + convertWeatherType(weatherType) { + const weatherTypes = { + "clear-day": "day-sunny", + "clear-night": "night-clear", + rain: "rain", + snow: "snow", + sleet: "snow", + wind: "wind", + fog: "fog", + cloudy: "cloudy", + "partly-cloudy-day": "day-cloudy", + "partly-cloudy-night": "night-cloudy" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/smhi.js b/mm/mounts/modules/default/weather/providers/smhi.js new file mode 100644 index 0000000..0cdd85f --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/smhi.js @@ -0,0 +1,334 @@ +/* global WeatherProvider, WeatherObject */ + +/* MagicMirror² + * Module: Weather + * Provider: SMHI + * + * By BuXXi https://github.com/buxxi + * MIT Licensed + * + * This class is a provider for SMHI (Sweden only). Metric system is the only + * supported unit. + */ +WeatherProvider.register("smhi", { + providerName: "SMHI", + + // Set the default config properties that is specific to this provider + defaults: { + lat: 0, // Cant have more than 6 digits + lon: 0, // Cant have more than 6 digits + precipitationValue: "pmedian", + location: false + }, + + /** + * Implements method in interface for fetching current weather. + */ + fetchCurrentWeather() { + this.fetchData(this.getURL()) + .then((data) => { + const closest = this.getClosestToCurrentTime(data.timeSeries); + const coordinates = this.resolveCoordinates(data); + const weatherObject = this.convertWeatherDataToObject(closest, coordinates); + this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); + this.setCurrentWeather(weatherObject); + }) + .catch((error) => Log.error(`Could not load data: ${error.message}`)) + .finally(() => this.updateAvailable()); + }, + + /** + * Implements method in interface for fetching a multi-day forecast. + */ + fetchWeatherForecast() { + this.fetchData(this.getURL()) + .then((data) => { + const coordinates = this.resolveCoordinates(data); + const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates); + this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); + this.setWeatherForecast(weatherObjects); + }) + .catch((error) => Log.error(`Could not load data: ${error.message}`)) + .finally(() => this.updateAvailable()); + }, + + /** + * Implements method in interface for fetching hourly forecasts. + */ + fetchWeatherHourly() { + this.fetchData(this.getURL()) + .then((data) => { + const coordinates = this.resolveCoordinates(data); + const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates, "hour"); + this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); + this.setWeatherHourly(weatherObjects); + }) + .catch((error) => Log.error(`Could not load data: ${error.message}`)) + .finally(() => this.updateAvailable()); + }, + + /** + * Overrides method for setting config with checks for the precipitationValue being unset or invalid + * @param {object} config The configuration object + */ + setConfig(config) { + this.config = config; + if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) { + Log.log(`invalid or not set: ${config.precipitationValue}`); + config.precipitationValue = this.defaults.precipitationValue; + } + }, + + /** + * Of all the times returned find out which one is closest to the current time, should be the first if the data isn't old. + * @param {object[]} times Array of time objects + * @returns {object} The weatherdata closest to the current time + */ + getClosestToCurrentTime(times) { + let now = moment(); + let minDiff = undefined; + for (const time of times) { + let diff = Math.abs(moment(time.validTime).diff(now)); + if (!minDiff || diff < Math.abs(moment(minDiff.validTime).diff(now))) { + minDiff = time; + } + } + return minDiff; + }, + + /** + * Get the forecast url for the configured coordinates + * @returns {string} the url for the specified coordinates + */ + getURL() { + const formatter = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 6, + maximumFractionDigits: 6 + }); + const lon = formatter.format(this.config.lon); + const lat = formatter.format(this.config.lat); + return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`; + }, + + /** + * Calculates the apparent temperature based on known atmospheric data. + * @param {object} weatherData Weatherdata to use for the calculation + * @returns {number} The apparent temperature + */ + calculateApparentTemperature(weatherData) { + const Ta = this.paramValue(weatherData, "t"); + const rh = this.paramValue(weatherData, "r"); + const ws = this.paramValue(weatherData, "ws"); + const p = (rh / 100) * 6.105 * Math.E * ((17.27 * Ta) / (237.7 + Ta)); + + return Ta + 0.33 * p - 0.7 * ws - 4; + }, + + /** + * Converts the returned data into a WeatherObject with required properties set for both current weather and forecast. + * The returned units is always in metric system. + * Requires coordinates to determine if its daytime or nighttime to know which icon to use and also to set sunrise and sunset. + * @param {object} weatherData Weatherdata to convert + * @param {object} coordinates Coordinates of the locations of the weather + * @returns {WeatherObject} The converted weatherdata at the specified location + */ + convertWeatherDataToObject(weatherData, coordinates) { + let currentWeather = new WeatherObject(); + + currentWeather.date = moment(weatherData.validTime); + currentWeather.updateSunTime(coordinates.lat, coordinates.lon); + currentWeather.humidity = this.paramValue(weatherData, "r"); + currentWeather.temperature = this.paramValue(weatherData, "t"); + currentWeather.windSpeed = this.paramValue(weatherData, "ws"); + currentWeather.windFromDirection = this.paramValue(weatherData, "wd"); + currentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, "Wsymb2"), currentWeather.isDayTime()); + currentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData); + + // Determine the precipitation amount and category and update the + // weatherObject with it, the valuetype to use can be configured or uses + // median as default. + let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue); + switch (this.paramValue(weatherData, "pcat")) { + // 0 = No precipitation + case 1: // Snow + currentWeather.snow += precipitationValue; + currentWeather.precipitationAmount += precipitationValue; + break; + case 2: // Snow and rain, treat it as 50/50 snow and rain + currentWeather.snow += precipitationValue / 2; + currentWeather.rain += precipitationValue / 2; + currentWeather.precipitationAmount += precipitationValue; + break; + case 3: // Rain + case 4: // Drizzle + case 5: // Freezing rain + case 6: // Freezing drizzle + currentWeather.rain += precipitationValue; + currentWeather.precipitationAmount += precipitationValue; + break; + } + + return currentWeather; + }, + + /** + * Takes all the data points and converts it to one WeatherObject per day. + * @param {object[]} allWeatherData Array of weatherdata + * @param {object} coordinates Coordinates of the locations of the weather + * @param {string} groupBy The interval to use for grouping the data (day, hour) + * @returns {WeatherObject[]} Array of weatherobjects + */ + convertWeatherDataGroupedBy(allWeatherData, coordinates, groupBy = "day") { + let currentWeather; + let result = []; + + let allWeatherObjects = this.fillInGaps(allWeatherData).map((weatherData) => this.convertWeatherDataToObject(weatherData, coordinates)); + let dayWeatherTypes = []; + + for (const weatherObject of allWeatherObjects) { + //If its the first object or if a day/hour change we need to reset the summary object + if (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) { + currentWeather = new WeatherObject(); + dayWeatherTypes = []; + currentWeather.temperature = weatherObject.temperature; + currentWeather.date = weatherObject.date; + currentWeather.minTemperature = Infinity; + currentWeather.maxTemperature = -Infinity; + currentWeather.snow = 0; + currentWeather.rain = 0; + currentWeather.precipitationAmount = 0; + result.push(currentWeather); + } + + //Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast + if (weatherObject.isDayTime()) { + dayWeatherTypes.push(weatherObject.weatherType); + } + if (dayWeatherTypes.length > 0) { + currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)]; + } else { + currentWeather.weatherType = weatherObject.weatherType; + } + + //All other properties is either a sum, min or max of each hour + currentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature); + currentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature); + currentWeather.snow += weatherObject.snow; + currentWeather.rain += weatherObject.rain; + currentWeather.precipitationAmount += weatherObject.precipitationAmount; + } + + return result; + }, + + /** + * Resolve coordinates from the response data (probably preferably to use + * this if it's not matching the config values exactly) + * @param {object} data Response data from the weather service + * @returns {{lon, lat}} the lat/long coordinates of the data + */ + resolveCoordinates(data) { + return { lat: data.geometry.coordinates[0][1], lon: data.geometry.coordinates[0][0] }; + }, + + /** + * The distance between the data points is increasing in the data the more distant the prediction is. + * Find these gaps and fill them with the previous hours data to make the data returned a complete set. + * @param {object[]} data Response data from the weather service + * @returns {object[]} Given data with filled gaps + */ + fillInGaps(data) { + let result = []; + for (let i = 1; i < data.length; i++) { + let to = moment(data[i].validTime); + let from = moment(data[i - 1].validTime); + let hours = moment.duration(to.diff(from)).asHours(); + // For each hour add a datapoint but change the validTime + for (let j = 0; j < hours; j++) { + let current = Object.assign({}, data[i]); + current.validTime = from.clone().add(j, "hours").toISOString(); + result.push(current); + } + } + return result; + }, + + /** + * Helper method to get a property from the returned data set. + * @param {object} currentWeatherData Weatherdata to get from + * @param {string} name The name of the property + * @returns {*} The value of the property in the weatherdata + */ + paramValue(currentWeatherData, name) { + return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0]; + }, + + /** + * Map the icon value from SMHI to an icon that MagicMirror² understands. + * Uses different icons depending on if its daytime or nighttime. + * SMHI's description of what the numeric value means is the comment after the case. + * @param {number} input The SMHI icon value + * @param {boolean} isDayTime True if the icon should be for daytime, false for nighttime + * @returns {string} The icon name for the MagicMirror + */ + convertWeatherType(input, isDayTime) { + switch (input) { + case 1: + return isDayTime ? "day-sunny" : "night-clear"; // Clear sky + case 2: + return isDayTime ? "day-sunny-overcast" : "night-partly-cloudy"; // Nearly clear sky + case 3: + return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable cloudiness + case 4: + return isDayTime ? "day-cloudy" : "night-cloudy"; // Halfclear sky + case 5: + return "cloudy"; // Cloudy sky + case 6: + return "cloudy"; // Overcast + case 7: + return "fog"; // Fog + case 8: + return "showers"; // Light rain showers + case 9: + return "showers"; // Moderate rain showers + case 10: + return "showers"; // Heavy rain showers + case 11: + return "thunderstorm"; // Thunderstorm + case 12: + return "sleet"; // Light sleet showers + case 13: + return "sleet"; // Moderate sleet showers + case 14: + return "sleet"; // Heavy sleet showers + case 15: + return "snow"; // Light snow showers + case 16: + return "snow"; // Moderate snow showers + case 17: + return "snow"; // Heavy snow showers + case 18: + return "rain"; // Light rain + case 19: + return "rain"; // Moderate rain + case 20: + return "rain"; // Heavy rain + case 21: + return "thunderstorm"; // Thunder + case 22: + return "sleet"; // Light sleet + case 23: + return "sleet"; // Moderate sleet + case 24: + return "sleet"; // Heavy sleet + case 25: + return "snow"; // Light snowfall + case 26: + return "snow"; // Moderate snowfall + case 27: + return "snow"; // Heavy snowfall + default: + return ""; + } + } +}); diff --git a/mm/mounts/modules/default/weather/providers/ukmetoffice.js b/mm/mounts/modules/default/weather/providers/ukmetoffice.js new file mode 100644 index 0000000..49c7d80 --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/ukmetoffice.js @@ -0,0 +1,201 @@ +/* global WeatherProvider, WeatherObject, WeatherUtils */ + +/* MagicMirror² + * Module: Weather + * + * By Malcolm Oakes https://github.com/maloakes + * MIT Licensed. + * + * This class is a provider for UK Met Office Datapoint. + */ +WeatherProvider.register("ukmetoffice", { + // Set the name of the provider. + // This isn't strictly necessary, since it will fallback to the provider identifier + // But for debugging (and future alerts) it would be nice to have the real name. + providerName: "UK Met Office", + + // Set the default config properties that is specific to this provider + defaults: { + apiBase: "http://datapoint.metoffice.gov.uk/public/data/val/wxfcs/all/json/", + locationID: false, + apiKey: "" + }, + + // Overwrite the fetchCurrentWeather method. + fetchCurrentWeather() { + this.fetchData(this.getUrl("3hourly")) + .then((data) => { + if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) { + // Did not receive usable new data. + // Maybe this needs a better check? + return; + } + + this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`); + + const currentWeather = this.generateWeatherObjectFromCurrentWeather(data); + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // Overwrite the fetchCurrentWeather method. + fetchWeatherForecast() { + this.fetchData(this.getUrl("daily")) + .then((data) => { + if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) { + // Did not receive usable new data. + // Maybe this needs a better check? + return; + } + + this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`); + + const forecast = this.generateWeatherObjectsFromForecast(data); + this.setWeatherForecast(forecast); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + /** UK Met Office Specific Methods - These are not part of the default provider methods */ + /* + * Gets the complete url for the request + */ + getUrl(forecastType) { + return this.config.apiBase + this.config.locationID + this.getParams(forecastType); + }, + + /* + * Generate a WeatherObject based on currentWeatherInformation + */ + generateWeatherObjectFromCurrentWeather(currentWeatherData) { + const currentWeather = new WeatherObject(); + const location = currentWeatherData.SiteRep.DV.Location; + + // data times are always UTC + let nowUtc = moment.utc(); + let midnightUtc = nowUtc.clone().startOf("day"); + let timeInMins = nowUtc.diff(midnightUtc, "minutes"); + + // loop round each of the (5) periods, look for today (the first period may be yesterday) + for (const period of location.Period) { + const periodDate = moment.utc(period.value.substr(0, 10), "YYYY-MM-DD"); + + // ignore if period is before today + if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) { + // check this is the period we want, after today the diff will be -ve + if (moment().diff(periodDate, "minutes") > 0) { + // loop round the reports looking for the one we are in + // $ value specifies the time in minutes-of-the-day: 0, 180, 360,...1260 + for (const rep of period.Rep) { + const p = rep.$; + if (timeInMins >= p && timeInMins - 180 < p) { + // finally got the one we want, so populate weather object + currentWeather.humidity = rep.H; + currentWeather.temperature = rep.T; + currentWeather.feelsLikeTemp = rep.F; + currentWeather.precipitationProbability = parseInt(rep.Pp); + currentWeather.windSpeed = WeatherUtils.convertWindToMetric(rep.S); + currentWeather.windFromDirection = WeatherUtils.convertWindDirection(rep.D); + currentWeather.weatherType = this.convertWeatherType(rep.W); + } + } + } + } + } + + // determine the sunrise/sunset times - not supplied in UK Met Office data + currentWeather.updateSunTime(location.lat, location.lon); + + return currentWeather; + }, + + /* + * Generate WeatherObjects based on forecast information + */ + generateWeatherObjectsFromForecast(forecasts) { + const days = []; + + // loop round the (5) periods getting the data + // for each period array, Day is [0], Night is [1] + for (const period of forecasts.SiteRep.DV.Location.Period) { + const weather = new WeatherObject(); + + // data times are always UTC + const dateStr = period.value; + let periodDate = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD"); + + // ignore if period is before today + if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) { + // populate the weather object + weather.date = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD"); + weather.minTemperature = period.Rep[1].Nm; + weather.maxTemperature = period.Rep[0].Dm; + weather.weatherType = this.convertWeatherType(period.Rep[0].W); + weather.precipitationProbability = parseInt(period.Rep[0].PPd); + + days.push(weather); + } + } + + return days; + }, + + /* + * Convert the Met Office icons to a more usable name. + */ + convertWeatherType(weatherType) { + const weatherTypes = { + 0: "night-clear", + 1: "day-sunny", + 2: "night-alt-cloudy", + 3: "day-cloudy", + 5: "fog", + 6: "fog", + 7: "cloudy", + 8: "cloud", + 9: "night-sprinkle", + 10: "day-sprinkle", + 11: "raindrops", + 12: "sprinkle", + 13: "night-alt-showers", + 14: "day-showers", + 15: "rain", + 16: "night-alt-sleet", + 17: "day-sleet", + 18: "sleet", + 19: "night-alt-hail", + 20: "day-hail", + 21: "hail", + 22: "night-alt-snow", + 23: "day-snow", + 24: "snow", + 25: "night-alt-snow", + 26: "day-snow", + 27: "snow", + 28: "night-alt-thunderstorm", + 29: "day-thunderstorm", + 30: "thunderstorm" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + }, + + /** + * Generates an url with api parameters based on the config. + * @param {string} forecastType daily or 3hourly forecast + * @returns {string} url + */ + getParams(forecastType) { + let params = "?"; + params += `res=${forecastType}`; + params += `&key=${this.config.apiKey}`; + return params; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/ukmetofficedatahub.js b/mm/mounts/modules/default/weather/providers/ukmetofficedatahub.js new file mode 100644 index 0000000..a4d4126 --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/ukmetofficedatahub.js @@ -0,0 +1,271 @@ +/* global WeatherProvider, WeatherObject */ + +/* MagicMirror² + * Module: Weather + * + * By Malcolm Oakes https://github.com/maloakes + * Existing Met Office provider edited for new MetOffice Data Hub by CreepinJesus http://github.com/XBCreepinJesus + * MIT Licensed. + * + * This class is a provider for UK Met Office Data Hub (the replacement for their Data Point services). + * For more information on Data Hub, see https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub + * Data available: + * Hourly data for next 2 days ("hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-hourly.pdf + * 3-hourly data for the next 7 days ("3hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-3-hourly.pdf + * Daily data for the next 7 days ("daily") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-daily.pdf + * + * NOTES + * This provider requires longitude/latitude coordinates, rather than a location ID (as with the previous Met Office provider) + * Provide the following in your config.js file: + * weatherProvider: "ukmetofficedatahub", + * apiBase: "https://api-metoffice.apiconnect.ibmcloud.com/metoffice/production/v0/forecasts/point/", + * apiKey: "[YOUR API KEY]", + * apiSecret: "[YOUR API SECRET]", + * lat: [LATITUDE (DECIMAL)], + * lon: [LONGITUDE (DECIMAL)] + * + * At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when + * setting your update intervals. For reference, 360 requests per day is once every 4 minutes. + * + * Pay attention to the units of the supplied data from the Met Office - it is given in SI/metric units where applicable: + * - Temperatures are in degrees Celsius (°C) + * - Wind speeds are in metres per second (m/s) + * - Wind direction given in degrees (°) + * - Pressures are in Pascals (Pa) + * - Distances are in metres (m) + * - Probabilities and humidity are given as percentages (%) + * - Precipitation is measured in millimetres (mm) with rates per hour (mm/h) + * + * See the PDFs linked above for more information on the data their corresponding units. + */ + +WeatherProvider.register("ukmetofficedatahub", { + // Set the name of the provider. + providerName: "UK Met Office (DataHub)", + + // Set the default config properties that is specific to this provider + defaults: { + apiBase: "https://api-metoffice.apiconnect.ibmcloud.com/metoffice/production/v0/forecasts/point/", + apiKey: "", + apiSecret: "", + lat: 0, + lon: 0 + }, + + // Build URL with query strings according to DataHub API (https://metoffice.apiconnect.ibmcloud.com/metoffice/production/api) + getUrl(forecastType) { + let queryStrings = "?"; + queryStrings += `latitude=${this.config.lat}`; + queryStrings += `&longitude=${this.config.lon}`; + queryStrings += `&includeLocationName=${true}`; + + // Return URL, making sure there is a trailing "/" in the base URL. + return this.config.apiBase + (this.config.apiBase.endsWith("/") ? "" : "/") + forecastType + queryStrings; + }, + + // Build the list of headers for the request + // For DataHub requests, the API key/secret are sent in the headers rather than as query strings. + // Headers defined according to Data Hub API (https://metoffice.apiconnect.ibmcloud.com/metoffice/production/api) + getHeaders() { + return { + accept: "application/json", + "x-ibm-client-id": this.config.apiKey, + "x-ibm-client-secret": this.config.apiSecret + }; + }, + + // Fetch data using supplied URL and request headers + async fetchWeather(url, headers) { + const response = await fetch(url, { headers: headers }); + + // Return JSON data + return response.json(); + }, + + // Fetch hourly forecast data (to use for current weather) + fetchCurrentWeather() { + this.fetchWeather(this.getUrl("hourly"), this.getHeaders()) + .then((data) => { + // Check data is usable + if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { + // Did not receive usable new data. + // Maybe this needs a better check? + Log.error("Possibly bad current/hourly data?"); + Log.error(data); + return; + } + + // Set location name + this.setFetchedLocation(`${data.features[0].properties.location.name}`); + + // Generate current weather data + const currentWeather = this.generateWeatherObjectFromCurrentWeather(data); + this.setCurrentWeather(currentWeather); + }) + + // Catch any error(s) + .catch((error) => Log.error(`Could not load data: ${error.message}`)) + + // Let the module know there is data available + .finally(() => this.updateAvailable()); + }, + + // Create a WeatherObject using current weather data (data for the current hour) + generateWeatherObjectFromCurrentWeather(currentWeatherData) { + const currentWeather = new WeatherObject(); + + // Extract the actual forecasts + let forecastDataHours = currentWeatherData.features[0].properties.timeSeries; + + // Define now + let nowUtc = moment.utc(); + + // Find hour that contains the current time + for (let hour in forecastDataHours) { + let forecastTime = moment.utc(forecastDataHours[hour].time); + if (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, "h")))) { + currentWeather.date = forecastTime; + currentWeather.windSpeed = forecastDataHours[hour].windSpeed10m; + currentWeather.windFromDirection = forecastDataHours[hour].windDirectionFrom10m; + currentWeather.temperature = forecastDataHours[hour].screenTemperature; + currentWeather.minTemperature = forecastDataHours[hour].minScreenAirTemp; + currentWeather.maxTemperature = forecastDataHours[hour].maxScreenAirTemp; + currentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode); + currentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity; + currentWeather.rain = forecastDataHours[hour].totalPrecipAmount; + currentWeather.snow = forecastDataHours[hour].totalSnowAmount; + currentWeather.precipitationProbability = forecastDataHours[hour].probOfPrecipitation; + currentWeather.feelsLikeTemp = forecastDataHours[hour].feelsLikeTemperature; + + // Pass on full details, so they can be used in custom templates + // Note the units of the supplied data when using this (see top of file) + currentWeather.rawData = forecastDataHours[hour]; + } + } + + // Determine the sunrise/sunset times - (still) not supplied in UK Met Office data + // Passes {longitude, latitude} to SunCalc, could pass height to, but + // SunCalc.getTimes doesn't take that into account + currentWeather.updateSunTime(this.config.lat, this.config.lon); + + return currentWeather; + }, + + // Fetch daily forecast data + fetchWeatherForecast() { + this.fetchWeather(this.getUrl("daily"), this.getHeaders()) + .then((data) => { + // Check data is usable + if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { + // Did not receive usable new data. + // Maybe this needs a better check? + Log.error("Possibly bad forecast data?"); + Log.error(data); + return; + } + + // Set location name + this.setFetchedLocation(`${data.features[0].properties.location.name}`); + + // Generate the forecast data + const forecast = this.generateWeatherObjectsFromForecast(data); + this.setWeatherForecast(forecast); + }) + + // Catch any error(s) + .catch((error) => Log.error(`Could not load data: ${error.message}`)) + + // Let the module know there is new data available + .finally(() => this.updateAvailable()); + }, + + // Create a WeatherObject for each day using daily forecast data + generateWeatherObjectsFromForecast(forecasts) { + const dailyForecasts = []; + + // Extract the actual forecasts + let forecastDataDays = forecasts.features[0].properties.timeSeries; + + // Define today + let today = moment.utc().startOf("date"); + + // Go through each day in the forecasts + for (let day in forecastDataDays) { + const forecastWeather = new WeatherObject(); + + // Get date of forecast + let forecastDate = moment.utc(forecastDataDays[day].time); + + // Check if forecast is for today or in the future (i.e., ignore yesterday's forecast) + if (forecastDate.isSameOrAfter(today)) { + forecastWeather.date = forecastDate; + forecastWeather.minTemperature = forecastDataDays[day].nightMinScreenTemperature; + forecastWeather.maxTemperature = forecastDataDays[day].dayMaxScreenTemperature; + + // Using daytime forecast values + forecastWeather.windSpeed = forecastDataDays[day].midday10MWindSpeed; + forecastWeather.windFromDirection = forecastDataDays[day].midday10MWindDirection; + forecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode); + forecastWeather.precipitationProbability = forecastDataDays[day].dayProbabilityOfPrecipitation; + forecastWeather.temperature = forecastDataDays[day].dayMaxScreenTemperature; + forecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity; + forecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain; + forecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow; + forecastWeather.feelsLikeTemp = forecastDataDays[day].dayMaxFeelsLikeTemp; + + // Pass on full details, so they can be used in custom templates + // Note the units of the supplied data when using this (see top of file) + forecastWeather.rawData = forecastDataDays[day]; + + dailyForecasts.push(forecastWeather); + } + } + + return dailyForecasts; + }, + + // Set the fetched location name. + setFetchedLocation: function (name) { + this.fetchedLocationName = name; + }, + + // Match the Met Office "significant weather code" to a weathericons.css icon + // Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264 + // and: https://erikflowers.github.io/weather-icons/ + convertWeatherType(weatherType) { + const weatherTypes = { + 0: "night-clear", + 1: "day-sunny", + 2: "night-alt-cloudy", + 3: "day-cloudy", + 5: "fog", + 6: "fog", + 7: "cloudy", + 8: "cloud", + 9: "night-sprinkle", + 10: "day-sprinkle", + 11: "raindrops", + 12: "sprinkle", + 13: "night-alt-showers", + 14: "day-showers", + 15: "rain", + 16: "night-alt-sleet", + 17: "day-sleet", + 18: "sleet", + 19: "night-alt-hail", + 20: "day-hail", + 21: "hail", + 22: "night-alt-snow", + 23: "day-snow", + 24: "snow", + 25: "night-alt-snow", + 26: "day-snow", + 27: "snow", + 28: "night-alt-thunderstorm", + 29: "day-thunderstorm", + 30: "thunderstorm" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/weatherbit.js b/mm/mounts/modules/default/weather/providers/weatherbit.js new file mode 100644 index 0000000..298d23b --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/weatherbit.js @@ -0,0 +1,208 @@ +/* global WeatherProvider, WeatherObject */ + +/* MagicMirror² + * Module: Weather + * Provider: Weatherbit + * + * By Andrew Pometti + * MIT Licensed + * + * This class is a provider for Weatherbit, based on Nicholas Hubbard's class + * for Dark Sky & Vince Peri's class for Weather.gov. + */ +WeatherProvider.register("weatherbit", { + // Set the name of the provider. + // Not strictly required, but helps for debugging. + providerName: "Weatherbit", + + // Set the default config properties that is specific to this provider + defaults: { + apiBase: "https://api.weatherbit.io/v2.0", + apiKey: "", + lat: 0, + lon: 0 + }, + + fetchedLocation: function () { + return this.fetchedLocationName || ""; + }, + + fetchCurrentWeather() { + this.fetchData(this.getUrl()) + .then((data) => { + if (!data || !data.data[0] || typeof data.data[0].temp === "undefined") { + // No usable data? + return; + } + + const currentWeather = this.generateWeatherDayFromCurrentWeather(data); + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + fetchWeatherForecast() { + this.fetchData(this.getUrl()) + .then((data) => { + if (!data || !data.data) { + // No usable data? + return; + } + + const forecast = this.generateWeatherObjectsFromForecast(data.data); + this.setWeatherForecast(forecast); + + this.fetchedLocationName = `${data.city_name}, ${data.state_code}`; + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + /** + * Overrides method for setting config to check if endpoint is correct for hourly + * @param {object} config The configuration object + */ + setConfig(config) { + this.config = config; + if (!this.config.weatherEndpoint) { + switch (this.config.type) { + case "hourly": + this.config.weatherEndpoint = "/forecast/hourly"; + break; + case "daily": + case "forecast": + this.config.weatherEndpoint = "/forecast/daily"; + break; + case "current": + this.config.weatherEndpoint = "/current"; + break; + default: + Log.error("weatherEndpoint not configured and could not resolve it based on type"); + } + } + }, + + // Create a URL from the config and base URL. + getUrl() { + return `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`; + }, + + // Implement WeatherDay generator. + generateWeatherDayFromCurrentWeather(currentWeatherData) { + //Calculate TZ Offset and invert to convert Sunrise/Sunset times to Local + const d = new Date(); + let tzOffset = d.getTimezoneOffset(); + tzOffset = tzOffset * -1; + + const currentWeather = new WeatherObject(); + + currentWeather.date = moment.unix(currentWeatherData.data[0].ts); + currentWeather.humidity = parseFloat(currentWeatherData.data[0].rh); + currentWeather.temperature = parseFloat(currentWeatherData.data[0].temp); + currentWeather.windSpeed = parseFloat(currentWeatherData.data[0].wind_spd); + currentWeather.windFromDirection = currentWeatherData.data[0].wind_dir; + currentWeather.weatherType = this.convertWeatherType(currentWeatherData.data[0].weather.icon); + currentWeather.sunrise = moment(currentWeatherData.data[0].sunrise, "HH:mm").add(tzOffset, "m"); + currentWeather.sunset = moment(currentWeatherData.data[0].sunset, "HH:mm").add(tzOffset, "m"); + + this.fetchedLocationName = `${currentWeatherData.data[0].city_name}, ${currentWeatherData.data[0].state_code}`; + + return currentWeather; + }, + + generateWeatherObjectsFromForecast(forecasts) { + const days = []; + + for (const forecast of forecasts) { + const weather = new WeatherObject(); + + weather.date = moment(forecast.datetime, "YYYY-MM-DD"); + weather.minTemperature = forecast.min_temp; + weather.maxTemperature = forecast.max_temp; + weather.precipitationAmount = forecast.precip; + weather.precipitationProbability = forecast.pop; + weather.weatherType = this.convertWeatherType(forecast.weather.icon); + + days.push(weather); + } + + return days; + }, + + // Map icons from Dark Sky to our icons. + convertWeatherType(weatherType) { + const weatherTypes = { + t01d: "day-thunderstorm", + t01n: "night-alt-thunderstorm", + t02d: "day-thunderstorm", + t02n: "night-alt-thunderstorm", + t03d: "thunderstorm", + t03n: "thunderstorm", + t04d: "day-thunderstorm", + t04n: "night-alt-thunderstorm", + t05d: "day-sleet-storm", + t05n: "night-alt-sleet-storm", + d01d: "day-sprinkle", + d01n: "night-alt-sprinkle", + d02d: "day-sprinkle", + d02n: "night-alt-sprinkle", + d03d: "day-shower", + d03n: "night-alt-shower", + r01d: "day-shower", + r01n: "night-alt-shower", + r02d: "day-rain", + r02n: "night-alt-rain", + r03d: "day-rain", + r03n: "night-alt-rain", + r04d: "day-sprinkle", + r04n: "night-alt-sprinkle", + r05d: "day-shower", + r05n: "night-alt-shower", + r06d: "day-shower", + r06n: "night-alt-shower", + f01d: "day-sleet", + f01n: "night-alt-sleet", + s01d: "day-snow", + s01n: "night-alt-snow", + s02d: "day-snow-wind", + s02n: "night-alt-snow-wind", + s03d: "snowflake-cold", + s03n: "snowflake-cold", + s04d: "day-rain-mix", + s04n: "night-alt-rain-mix", + s05d: "day-sleet", + s05n: "night-alt-sleet", + s06d: "day-snow", + s06n: "night-alt-snow", + a01d: "day-haze", + a01n: "dust", + a02d: "smoke", + a02n: "smoke", + a03d: "day-haze", + a03n: "dust", + a04d: "dust", + a04n: "dust", + a05d: "day-fog", + a05n: "night-fog", + a06d: "fog", + a06n: "fog", + c01d: "day-sunny", + c01n: "night-clear", + c02d: "day-sunny-overcast", + c02n: "night-alt-partly-cloudy", + c03d: "day-cloudy", + c03n: "night-alt-cloudy", + c04d: "cloudy", + c04n: "cloudy", + u00d: "rain-mix", + u00n: "rain-mix" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/weatherflow.js b/mm/mounts/modules/default/weather/providers/weatherflow.js new file mode 100644 index 0000000..aecfb63 --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/weatherflow.js @@ -0,0 +1,77 @@ +/* global WeatherProvider, WeatherObject, WeatherUtils */ + +/* MagicMirror² + * Module: Weather + * Provider: Weatherflow + * + * By Tobias Dreyem https://github.com/10bias + * MIT Licensed + * + * This class is a provider for Weatherflow. + * Note that the Weatherflow API does not provide snowfall. + */ + +WeatherProvider.register("weatherflow", { + // Set the name of the provider. + // Not strictly required, but helps for debugging + providerName: "WeatherFlow", + + // Set the default config properties that is specific to this provider + defaults: { + apiBase: "https://swd.weatherflow.com/swd/rest/", + token: "", + stationid: "" + }, + + fetchCurrentWeather() { + this.fetchData(this.getUrl()) + .then((data) => { + const currentWeather = new WeatherObject(); + currentWeather.date = moment(); + + currentWeather.humidity = data.current_conditions.relative_humidity; + currentWeather.temperature = data.current_conditions.air_temperature; + currentWeather.windSpeed = WeatherUtils.convertWindToMs(data.current_conditions.wind_avg); + currentWeather.windFromDirection = data.current_conditions.wind_direction; + currentWeather.weatherType = data.forecast.daily[0].icon; + currentWeather.sunrise = moment.unix(data.forecast.daily[0].sunrise); + currentWeather.sunset = moment.unix(data.forecast.daily[0].sunset); + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + fetchWeatherForecast() { + this.fetchData(this.getUrl()) + .then((data) => { + const days = []; + + for (const forecast of data.forecast.daily) { + const weather = new WeatherObject(); + + weather.date = moment.unix(forecast.day_start_local); + weather.minTemperature = forecast.air_temp_low; + weather.maxTemperature = forecast.air_temp_high; + weather.precipitationProbability = forecast.precip_probability; + weather.weatherType = forecast.icon; + weather.snow = 0; + + days.push(weather); + } + + this.setWeatherForecast(days); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // Create a URL from the config and base URL. + getUrl() { + return `${this.config.apiBase}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/weathergov.js b/mm/mounts/modules/default/weather/providers/weathergov.js new file mode 100644 index 0000000..b1c69ee --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/weathergov.js @@ -0,0 +1,367 @@ +/* global WeatherProvider, WeatherObject, WeatherUtils */ + +/* MagicMirror² + * Module: Weather + * Provider: weather.gov + * https://weather-gov.github.io/api/general-faqs + * + * Original by Vince Peri + * MIT Licensed. + * + * This class is a provider for weather.gov. + * Note that this is only for US locations (lat and lon) and does not require an API key + * Since it is free, there are some items missing - like sunrise, sunset + */ + +WeatherProvider.register("weathergov", { + // Set the name of the provider. + // This isn't strictly necessary, since it will fallback to the provider identifier + // But for debugging (and future alerts) it would be nice to have the real name. + providerName: "Weather.gov", + + // Set the default config properties that is specific to this provider + defaults: { + apiBase: "https://api.weather.gov/points/", + lat: 0, + lon: 0 + }, + + // Flag all needed URLs availability + configURLs: false, + + //This API has multiple urls involved + forecastURL: "tbd", + forecastHourlyURL: "tbd", + forecastGridDataURL: "tbd", + observationStationsURL: "tbd", + stationObsURL: "tbd", + + // Called to set the config, this config is the same as the weather module's config. + setConfig: function (config) { + this.config = config; + this.config.apiBase = "https://api.weather.gov"; + this.fetchWxGovURLs(this.config); + }, + + // Called when the weather provider is about to start. + start: function () { + Log.info(`Weather provider: ${this.providerName} started.`); + }, + + // This returns the name of the fetched location or an empty string. + fetchedLocation: function () { + return this.fetchedLocationName || ""; + }, + + // Overwrite the fetchCurrentWeather method. + fetchCurrentWeather() { + if (!this.configURLs) { + Log.info("fetchCurrentWeather: fetch wx waiting on config URLs"); + return; + } + this.fetchData(this.stationObsURL) + .then((data) => { + if (!data || !data.properties) { + // Did not receive usable new data. + return; + } + const currentWeather = this.generateWeatherObjectFromCurrentWeather(data.properties); + this.setCurrentWeather(currentWeather); + }) + .catch(function (request) { + Log.error("Could not load station obs data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // Overwrite the fetchWeatherForecast method. + fetchWeatherForecast() { + if (!this.configURLs) { + Log.info("fetchWeatherForecast: fetch wx waiting on config URLs"); + return; + } + this.fetchData(this.forecastURL) + .then((data) => { + if (!data || !data.properties || !data.properties.periods || !data.properties.periods.length) { + // Did not receive usable new data. + return; + } + const forecast = this.generateWeatherObjectsFromForecast(data.properties.periods); + this.setWeatherForecast(forecast); + }) + .catch(function (request) { + Log.error("Could not load forecast hourly data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + // Overwrite the fetchWeatherHourly method. + fetchWeatherHourly() { + if (!this.configURLs) { + Log.info("fetchWeatherHourly: fetch wx waiting on config URLs"); + return; + } + this.fetchData(this.forecastHourlyURL) + .then((data) => { + if (!data) { + // Did not receive usable new data. + // Maybe this needs a better check? + return; + } + const hourly = this.generateWeatherObjectsFromHourly(data.properties.periods); + this.setWeatherHourly(hourly); + }) + .catch(function (request) { + Log.error("Could not load data ... ", request); + }) + .finally(() => this.updateAvailable()); + }, + + /** Weather.gov Specific Methods - These are not part of the default provider methods */ + + /* + * Get specific URLs + */ + fetchWxGovURLs(config) { + this.fetchData(`${config.apiBase}/points/${config.lat},${config.lon}`) + .then((data) => { + if (!data || !data.properties) { + // points URL did not respond with usable data. + return; + } + this.fetchedLocationName = `${data.properties.relativeLocation.properties.city}, ${data.properties.relativeLocation.properties.state}`; + Log.log(`Forecast location is ${this.fetchedLocationName}`); + this.forecastURL = `${data.properties.forecast}?units=si`; + this.forecastHourlyURL = `${data.properties.forecastHourly}?units=si`; + this.forecastGridDataURL = data.properties.forecastGridData; + this.observationStationsURL = data.properties.observationStations; + // with this URL, we chain another promise for the station obs URL + return this.fetchData(data.properties.observationStations); + }) + .then((obsData) => { + if (!obsData || !obsData.features) { + // obs station URL did not respond with usable data. + return; + } + this.stationObsURL = `${obsData.features[0].id}/observations/latest`; + }) + .catch((err) => { + Log.error(err); + }) + .finally(() => { + // excellent, let's fetch some actual wx data + this.configURLs = true; + + // handle 'forecast' config, fall back to 'current' + if (config.type === "forecast") { + this.fetchWeatherForecast(); + } else if (config.type === "hourly") { + this.fetchWeatherHourly(); + } else { + this.fetchCurrentWeather(); + } + }); + }, + /* + * Generate a WeatherObject based on hourlyWeatherInformation + * Weather.gov API uses specific units; API does not include choice of units + * ... object needs data in units based on config! + */ + generateWeatherObjectsFromHourly(forecasts) { + const days = []; + + // variable for date + let weather = new WeatherObject(); + for (const forecast of forecasts) { + weather.date = moment(forecast.startTime.slice(0, 19)); + if (forecast.windSpeed.search(" ") < 0) { + weather.windSpeed = forecast.windSpeed; + } else { + weather.windSpeed = forecast.windSpeed.slice(0, forecast.windSpeed.search(" ")); + } + weather.windSpeed = WeatherUtils.convertWindToMs(weather.windSpeed); + weather.windFromDirection = forecast.windDirection; + weather.temperature = forecast.temperature; + // use the forecast isDayTime attribute to help build the weatherType label + weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); + + days.push(weather); + + weather = new WeatherObject(); + } + + // push weather information to days array + days.push(weather); + return days; + }, + + /* + * Generate a WeatherObject based on currentWeatherInformation + * Weather.gov API uses specific units; API does not include choice of units + * ... object needs data in units based on config! + */ + generateWeatherObjectFromCurrentWeather(currentWeatherData) { + const currentWeather = new WeatherObject(); + + currentWeather.date = moment(currentWeatherData.timestamp); + currentWeather.temperature = currentWeatherData.temperature.value; + currentWeather.windSpeed = WeatherUtils.convertWindToMs(currentWeatherData.windSpeed.value); + currentWeather.windFromDirection = currentWeatherData.windDirection.value; + currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value; + currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value; + currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value); + currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour.value; + if (currentWeatherData.heatIndex.value !== null) { + currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value; + } else if (currentWeatherData.windChill.value !== null) { + currentWeather.feelsLikeTemp = currentWeatherData.windChill.value; + } else { + currentWeather.feelsLikeTemp = currentWeatherData.temperature.value; + } + // determine the sunrise/sunset times - not supplied in weather.gov data + currentWeather.updateSunTime(this.config.lat, this.config.lon); + + // update weatherType + currentWeather.weatherType = this.convertWeatherType(currentWeatherData.textDescription, currentWeather.isDayTime()); + + return currentWeather; + }, + + /* + * Generate WeatherObjects based on forecast information + */ + generateWeatherObjectsFromForecast(forecasts) { + return this.fetchForecastDaily(forecasts); + }, + + /* + * fetch forecast information for daily forecast. + */ + fetchForecastDaily(forecasts) { + const precipitationProbabilityRegEx = "Chance of precipitation is ([0-9]+?)%"; + + // initial variable declaration + const days = []; + // variables for temperature range and rain + let minTemp = []; + let maxTemp = []; + // variable for date + let date = ""; + let weather = new WeatherObject(); + + for (const forecast of forecasts) { + if (date !== moment(forecast.startTime).format("YYYY-MM-DD")) { + // calculate minimum/maximum temperature, specify rain amount + weather.minTemperature = Math.min.apply(null, minTemp); + weather.maxTemperature = Math.max.apply(null, maxTemp); + + // push weather information to days array + days.push(weather); + // create new weather-object + weather = new WeatherObject(); + + minTemp = []; + maxTemp = []; + const precipitation = new RegExp(precipitationProbabilityRegEx, "g").exec(forecast.detailedForecast); + if (precipitation) weather.precipitationProbability = precipitation[1]; + + // set new date + date = moment(forecast.startTime).format("YYYY-MM-DD"); + + // specify date + weather.date = moment(forecast.startTime); + + // use the forecast isDayTime attribute to help build the weatherType label + weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); + } + + if (moment(forecast.startTime).format("H") >= 8 && moment(forecast.startTime).format("H") <= 17) { + weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); + } + + // the same day as before + // add values from forecast to corresponding variables + minTemp.push(forecast.temperature); + maxTemp.push(forecast.temperature); + } + + // last day + // calculate minimum/maximum temperature + weather.minTemperature = Math.min.apply(null, minTemp); + weather.maxTemperature = Math.max.apply(null, maxTemp); + + // push weather information to days array + days.push(weather); + return days.slice(1); + }, + + /* + * Convert the icons to a more usable name. + */ + convertWeatherType(weatherType, isDaytime) { + //https://w1.weather.gov/xml/current_obs/weather.php + // There are way too many types to create, so lets just look for certain strings + + if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) { + if (isDaytime) { + return "day-cloudy"; + } + + return "night-cloudy"; + } else if (weatherType.includes("Overcast")) { + if (isDaytime) { + return "cloudy"; + } + + return "night-cloudy"; + } else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) { + return "rain-mix"; + } else if (weatherType.includes("Snow")) { + if (isDaytime) { + return "snow"; + } + + return "night-snow"; + } else if (weatherType.includes("Thunderstorm")) { + if (isDaytime) { + return "thunderstorm"; + } + + return "night-thunderstorm"; + } else if (weatherType.includes("Showers")) { + if (isDaytime) { + return "showers"; + } + + return "night-showers"; + } else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) { + if (isDaytime) { + return "rain"; + } + + return "night-rain"; + } else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) { + if (isDaytime) { + return "cloudy-windy"; + } + + return "night-alt-cloudy-windy"; + } else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) { + if (isDaytime) { + return "day-sunny"; + } + + return "night-clear"; + } else if (weatherType.includes("Dust") || weatherType.includes("Sand")) { + return "dust"; + } else if (weatherType.includes("Fog")) { + return "fog"; + } else if (weatherType.includes("Smoke")) { + return "smoke"; + } else if (weatherType.includes("Haze")) { + return "day-haze"; + } + + return null; + } +}); diff --git a/mm/mounts/modules/default/weather/providers/yr.js b/mm/mounts/modules/default/weather/providers/yr.js new file mode 100644 index 0000000..52de53b --- /dev/null +++ b/mm/mounts/modules/default/weather/providers/yr.js @@ -0,0 +1,632 @@ +/* global WeatherProvider, WeatherObject */ + +/* MagicMirror² + * Module: Weather + * Provider: Yr.no + * + * By Magnus Marthinsen + * MIT Licensed + * + * This class is a provider for Yr.no, a norwegian weather service. + * + * Terms of service: https://developer.yr.no/doc/TermsOfService/ + */ +WeatherProvider.register("yr", { + providerName: "Yr", + + // Set the default config properties that is specific to this provider + defaults: { + useCorsProxy: true, + apiBase: "https://api.met.no/weatherapi", + altitude: 0, + currentForecastHours: 1 //1, 6 or 12 + }, + + start() { + if (typeof Storage === "undefined") { + //local storage unavailable + Log.error("The Yr weather provider requires local storage."); + throw new Error("Local storage not available"); + } + Log.info(`Weather provider: ${this.providerName} started.`); + }, + + fetchCurrentWeather() { + this.getCurrentWeather() + .then((currentWeather) => { + this.setCurrentWeather(currentWeather); + this.updateAvailable(); + }) + .catch((error) => { + Log.error(error); + throw new Error(error); + }); + }, + + async getCurrentWeather() { + const getRequests = [this.getWeatherData(), this.getStellarData()]; + const [weatherData, stellarData] = await Promise.all(getRequests); + if (!stellarData) { + Log.warn("No stellar data available."); + } + if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { + Log.error("No weather data available."); + return; + } + const currentTime = moment(); + let forecast = weatherData.properties.timeseries[0]; + let closestTimeInPast = currentTime.diff(moment(forecast.time)); + for (const forecastTime of weatherData.properties.timeseries) { + const comparison = currentTime.diff(moment(forecastTime.time)); + if (0 < comparison && comparison < closestTimeInPast) { + closestTimeInPast = comparison; + forecast = forecastTime; + } + } + const forecastXHours = this.getForecastForXHoursFrom(forecast.data); + forecast.weatherType = this.convertWeatherType(forecastXHours.summary.symbol_code, forecast.time); + forecast.precipitationAmount = forecastXHours.details?.precipitation_amount; + forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; + forecast.minTemperature = forecastXHours.details?.air_temperature_min; + forecast.maxTemperature = forecastXHours.details?.air_temperature_max; + return this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units); + }, + + getWeatherData() { + return new Promise((resolve, reject) => { + // If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. + // This is to avoid multiple similar calls to the API. + let shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); + if (shouldWait) { + const checkForGo = setInterval(function () { + shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); + }, 100); + setTimeout(function () { + clearInterval(checkForGo); + shouldWait = false; + }, 5000); //Assume other fetch finished but failed to remove lock + const attemptFetchWeather = setInterval(() => { + if (!shouldWait) { + clearInterval(checkForGo); + clearInterval(attemptFetchWeather); + this.getWeatherDataFromYrOrCache(resolve, reject); + } + }, 100); + } else { + this.getWeatherDataFromYrOrCache(resolve, reject); + } + }); + }, + + getWeatherDataFromYrOrCache(resolve, reject) { + localStorage.setItem("yrIsFetchingWeatherData", "true"); + + let weatherData = this.getWeatherDataFromCache(); + if (this.weatherDataIsValid(weatherData)) { + localStorage.removeItem("yrIsFetchingWeatherData"); + Log.debug("Weather data found in cache."); + resolve(weatherData); + } else { + this.getWeatherDataFromYr(weatherData?.downloadedAt) + .then((weatherData) => { + Log.debug("Got weather data from yr."); + let data; + if (weatherData) { + this.cacheWeatherData(weatherData); + data = weatherData; + } else { + //Undefined if unchanged + data = this.getWeatherDataFromCache(); + } + resolve(data); + }) + .catch((err) => { + Log.error(err); + reject("Unable to get weather data from Yr."); + }) + .finally(() => { + localStorage.removeItem("yrIsFetchingWeatherData"); + }); + } + }, + + weatherDataIsValid(weatherData) { + return ( + weatherData && + weatherData.timeout && + 0 < moment(weatherData.timeout).diff(moment()) && + (!weatherData.geometry || !weatherData.geometry.coordinates || !weatherData.geometry.coordinates.length < 2 || (weatherData.geometry.coordinates[0] === this.config.lat && weatherData.geometry.coordinates[1] === this.config.lon)) + ); + }, + + getWeatherDataFromCache() { + const weatherData = localStorage.getItem("weatherData"); + if (weatherData) { + return JSON.parse(weatherData); + } else { + return undefined; + } + }, + + getWeatherDataFromYr(currentDataFetchedAt) { + const requestHeaders = [{ name: "Accept", value: "application/json" }]; + if (currentDataFetchedAt) { + requestHeaders.push({ name: "If-Modified-Since", value: currentDataFetchedAt }); + } + + const expectedResponseHeaders = ["expires", "date"]; + + return this.fetchData(this.getForecastUrl(), "json", requestHeaders, expectedResponseHeaders) + .then((data) => { + if (!data || !data.headers) return data; + data.timeout = data.headers.find((header) => header.name === "expires").value; + data.downloadedAt = data.headers.find((header) => header.name === "date").value; + data.headers = undefined; + return data; + }) + .catch((err) => { + Log.error("Could not load weather data.", err); + throw new Error(err); + }); + }, + + getForecastUrl() { + if (!this.config.lat) { + Log.error("Latitude not provided."); + throw new Error("Latitude not provided."); + } + if (!this.config.lon) { + Log.error("Longitude not provided."); + throw new Error("Longitude not provided."); + } + + let lat = this.config.lat.toString(); + let lon = this.config.lon.toString(); + const altitude = this.config.altitude ?? 0; + + if (lat.includes(".") && lat.split(".")[1].length > 4) { + Log.warn("Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); + const latParts = lat.split("."); + lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; + } + if (lon.includes(".") && lon.split(".")[1].length > 4) { + Log.warn("Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); + const lonParts = lon.split("."); + lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; + } + + return `${this.config.apiBase}/locationforecast/2.0/complete?&altitude=${altitude}&lat=${lat}&lon=${lon}`; + }, + + cacheWeatherData(weatherData) { + localStorage.setItem("weatherData", JSON.stringify(weatherData)); + }, + + getAuthenticationString() { + if (!this.config.authenticationEmail) throw new Error("Authentication email not provided."); + return `${this.config.applicaitionName} ${this.config.authenticationEmail}`; + }, + + getStellarData() { + // If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. + // This is to avoid multiple similar calls to the API. + return new Promise((resolve, reject) => { + let shouldWait = localStorage.getItem("yrIsFetchingStellarData"); + if (shouldWait) { + const checkForGo = setInterval(function () { + shouldWait = localStorage.getItem("yrIsFetchingStellarData"); + }, 100); + setTimeout(function () { + clearInterval(checkForGo); + shouldWait = false; + }, 5000); //Assume other fetch finished but failed to remove lock + const attemptFetchWeather = setInterval(() => { + if (!shouldWait) { + clearInterval(checkForGo); + clearInterval(attemptFetchWeather); + this.getStellarDataFromYrOrCache(resolve, reject); + } + }, 100); + } else { + this.getStellarDataFromYrOrCache(resolve, reject); + } + }); + }, + + getStellarDataFromYrOrCache(resolve, reject) { + localStorage.setItem("yrIsFetchingStellarData", "true"); + + let stellarData = this.getStellarDataFromCache(); + const today = moment().format("YYYY-MM-DD"); + const tomorrow = moment().add(1, "days").format("YYYY-MM-DD"); + if (stellarData && stellarData.today && stellarData.today.date === today && stellarData.tomorrow && stellarData.tomorrow.date === tomorrow) { + Log.debug("Stellar data found in cache."); + localStorage.removeItem("yrIsFetchingStellarData"); + resolve(stellarData); + } else if (stellarData && stellarData.tomorrow && stellarData.tomorrow.date === today) { + Log.debug("stellar data for today found in cache, but not for tomorrow."); + stellarData.today = stellarData.tomorrow; + this.getStellarDataFromYr(tomorrow) + .then((data) => { + if (data) { + data.date = tomorrow; + stellarData.tomorrow = data; + this.cacheStellarData(stellarData); + resolve(stellarData); + } else { + reject(`No stellar data returned from Yr for ${tomorrow}`); + } + }) + .catch((err) => { + Log.error(err); + reject(`Unable to get stellar data from Yr for ${tomorrow}`); + }) + .finally(() => { + localStorage.removeItem("yrIsFetchingStellarData"); + }); + } else { + this.getStellarDataFromYr(today, 2) + .then((stellarData) => { + if (stellarData) { + const data = { + today: stellarData + }; + data.tomorrow = Object.assign({}, data.today); + data.today.date = today; + data.tomorrow.date = tomorrow; + this.cacheStellarData(data); + resolve(data); + } else { + Log.error(`Something went wrong when fetching stellar data. Responses: ${stellarData}`); + reject(stellarData); + } + }) + .catch((err) => { + Log.error(err); + reject("Unable to get stellar data from Yr."); + }) + .finally(() => { + localStorage.removeItem("yrIsFetchingStellarData"); + }); + } + }, + + getStellarDataFromCache() { + const stellarData = localStorage.getItem("stellarData"); + if (stellarData) { + return JSON.parse(stellarData); + } else { + return undefined; + } + }, + + getStellarDataFromYr(date, days = 1) { + const requestHeaders = [{ name: "Accept", value: "application/json" }]; + return this.fetchData(this.getStellarDatatUrl(date, days), "json", requestHeaders) + .then((data) => { + Log.debug("Got stellar data from yr."); + return data; + }) + .catch((err) => { + Log.error("Could not load weather data.", err); + throw new Error(err); + }); + }, + + getStellarDatatUrl(date, days) { + if (!this.config.lat) { + Log.error("Latitude not provided."); + throw new Error("Latitude not provided."); + } + if (!this.config.lon) { + Log.error("Longitude not provided."); + throw new Error("Longitude not provided."); + } + + let lat = this.config.lat.toString(); + let lon = this.config.lon.toString(); + const altitude = this.config.altitude ?? 0; + + if (lat.includes(".") && lat.split(".")[1].length > 4) { + Log.warn("Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); + const latParts = lat.split("."); + lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; + } + if (lon.includes(".") && lon.split(".")[1].length > 4) { + Log.warn("Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); + const lonParts = lon.split("."); + lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; + } + + let utcOffset = moment().utcOffset() / 60; + let utcOffsetPrefix = "%2B"; + if (utcOffset < 0) { + utcOffsetPrefix = "-"; + } + utcOffset = Math.abs(utcOffset); + let minutes = "00"; + if (utcOffset % 1 !== 0) { + minutes = "30"; + } + let hours = Math.floor(utcOffset).toString(); + if (hours.length < 2) { + hours = `0${hours}`; + } + + return `${this.config.apiBase}/sunrise/2.0/.json?date=${date}&days=${days}&height=${altitude}&lat=${lat}&lon=${lon}&offset=${utcOffsetPrefix}${hours}%3A${minutes}`; + }, + + cacheStellarData(data) { + localStorage.setItem("stellarData", JSON.stringify(data)); + }, + + getWeatherDataFrom(forecast, stellarData, units) { + const weather = new WeatherObject(); + const stellarTimesToday = stellarData?.today ? this.getStellarTimesFrom(stellarData.today, moment().format("YYYY-MM-DD")) : undefined; + const stellarTimesTomorrow = stellarData?.tomorrow ? this.getStellarTimesFrom(stellarData.tomorrow, moment().add(1, "days").format("YYYY-MM-DD")) : undefined; + + weather.date = moment(forecast.time); + weather.windSpeed = forecast.data.instant.details.wind_speed; + weather.windFromDirection = forecast.data.instant.details.wind_from_direction; + weather.temperature = forecast.data.instant.details.air_temperature; + weather.minTemperature = forecast.minTemperature; + weather.maxTemperature = forecast.maxTemperature; + weather.weatherType = forecast.weatherType; + weather.humidity = forecast.data.instant.details.relative_humidity; + weather.precipitationAmount = forecast.precipitationAmount; + weather.precipitationProbability = forecast.precipitationProbability; + weather.precipitationUnits = units.precipitation_amount; + + if (stellarTimesToday) { + weather.sunset = moment(stellarTimesToday.sunset.time); + weather.sunrise = weather.sunset < moment() && stellarTimesTomorrow ? moment(stellarTimesTomorrow.sunrise.time) : moment(stellarTimesToday.sunrise.time); + } + + return weather; + }, + + convertWeatherType(weatherType, weatherTime) { + const weatherHour = moment(weatherTime).format("HH"); + + const weatherTypes = { + clearsky_day: "day-sunny", + clearsky_night: "night-clear", + clearsky_polartwilight: weatherHour < 14 ? "sunrise" : "sunset", + cloudy: "cloudy", + fair_day: "day-sunny-overcast", + fair_night: "night-alt-partly-cloudy", + fair_polartwilight: "day-sunny-overcast", + fog: "fog", + heavyrain: "rain", // Possibly raindrops or raindrop + heavyrainandthunder: "thunderstorm", + heavyrainshowers_day: "day-rain", + heavyrainshowers_night: "night-alt-rain", + heavyrainshowers_polartwilight: "day-rain", + heavyrainshowersandthunder_day: "day-thunderstorm", + heavyrainshowersandthunder_night: "night-alt-thunderstorm", + heavyrainshowersandthunder_polartwilight: "day-thunderstorm", + heavysleet: "sleet", + heavysleetandthunder: "day-sleet-storm", + heavysleetshowers_day: "day-sleet", + heavysleetshowers_night: "night-alt-sleet", + heavysleetshowers_polartwilight: "day-sleet", + heavysleetshowersandthunder_day: "day-sleet-storm", + heavysleetshowersandthunder_night: "night-alt-sleet-storm", + heavysleetshowersandthunder_polartwilight: "day-sleet-storm", + heavysnow: "snow-wind", + heavysnowandthunder: "day-snow-thunderstorm", + heavysnowshowers_day: "day-snow-wind", + heavysnowshowers_night: "night-alt-snow-wind", + heavysnowshowers_polartwilight: "day-snow-wind", + heavysnowshowersandthunder_day: "day-snow-thunderstorm", + heavysnowshowersandthunder_night: "night-alt-snow-thunderstorm", + heavysnowshowersandthunder_polartwilight: "day-snow-thunderstorm", + lightrain: "rain-mix", + lightrainandthunder: "thunderstorm", + lightrainshowers_day: "day-rain-mix", + lightrainshowers_night: "night-alt-rain-mix", + lightrainshowers_polartwilight: "day-rain-mix", + lightrainshowersandthunder_day: "thunderstorm", + lightrainshowersandthunder_night: "thunderstorm", + lightrainshowersandthunder_polartwilight: "thunderstorm", + lightsleet: "day-sleet", + lightsleetandthunder: "day-sleet-storm", + lightsleetshowers_day: "day-sleet", + lightsleetshowers_night: "night-alt-sleet", + lightsleetshowers_polartwilight: "day-sleet", + lightsnow: "snowflake-cold", + lightsnowandthunder: "day-snow-thunderstorm", + lightsnowshowers_day: "day-snow-wind", + lightsnowshowers_night: "night-alt-snow-wind", + lightsnowshowers_polartwilight: "day-snow-wind", + lightssleetshowersandthunder_day: "day-sleet-storm", + lightssleetshowersandthunder_night: "night-alt-sleet-storm", + lightssleetshowersandthunder_polartwilight: "day-sleet-storm", + lightssnowshowersandthunder_day: "day-snow-thunderstorm", + lightssnowshowersandthunder_night: "night-alt-snow-thunderstorm", + lightssnowshowersandthunder_polartwilight: "day-snow-thunderstorm", + partlycloudy_day: "day-cloudy", + partlycloudy_night: "night-alt-cloudy", + partlycloudy_polartwilight: "day-cloudy", + rain: "rain", + rainandthunder: "thunderstorm", + rainshowers_day: "day-rain", + rainshowers_night: "night-alt-rain", + rainshowers_polartwilight: "day-rain", + rainshowersandthunder_day: "thunderstorm", + rainshowersandthunder_night: "lightning", + rainshowersandthunder_polartwilight: "thunderstorm", + sleet: "sleet", + sleetandthunder: "day-sleet-storm", + sleetshowers_day: "day-sleet", + sleetshowers_night: "night-alt-sleet", + sleetshowers_polartwilight: "day-sleet", + sleetshowersandthunder_day: "day-sleet-storm", + sleetshowersandthunder_night: "night-alt-sleet-storm", + sleetshowersandthunder_polartwilight: "day-sleet-storm", + snow: "snowflake-cold", + snowandthunder: "lightning", + snowshowers_day: "day-snow-wind", + snowshowers_night: "night-alt-snow-wind", + snowshowers_polartwilight: "day-snow-wind", + snowshowersandthunder_day: "day-snow-thunderstorm", + snowshowersandthunder_night: "night-alt-snow-thunderstorm", + snowshowersandthunder_polartwilight: "day-snow-thunderstorm" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + }, + + getStellarTimesFrom(stellarData, date) { + for (const time of stellarData.location.time) { + if (time.date === date) { + return time; + } + } + return undefined; + }, + + getForecastForXHoursFrom(weather) { + if (this.config.currentForecastHours === 1) { + if (weather.next_1_hours) { + return weather.next_1_hours; + } else if (weather.next_6_hours) { + return weather.next_6_hours; + } else { + return weather.next_12_hours; + } + } else if (this.config.currentForecastHours === 6) { + if (weather.next_6_hours) { + return weather.next_6_hours; + } else if (weather.next_12_hours) { + return weather.next_12_hours; + } else { + return weather.next_1_hours; + } + } else { + if (weather.next_12_hours) { + return weather.next_12_hours; + } else if (weather.next_6_hours) { + return weather.next_6_hours; + } else { + return weather.next_1_hours; + } + } + }, + + fetchWeatherHourly() { + this.getWeatherForecast("hourly") + .then((forecast) => { + this.setWeatherHourly(forecast); + this.updateAvailable(); + }) + .catch((error) => { + Log.error(error); + throw new Error(error); + }); + }, + + async getWeatherForecast(type) { + const getRequests = [this.getWeatherData(), this.getStellarData()]; + const [weatherData, stellarData] = await Promise.all(getRequests); + if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { + Log.error("No weather data available."); + return; + } + if (!stellarData) { + Log.warn("No stellar data available."); + } + let forecasts; + switch (type) { + case "hourly": + forecasts = this.getHourlyForecastFrom(weatherData); + break; + case "daily": + default: + forecasts = this.getDailyForecastFrom(weatherData); + break; + } + const series = []; + for (const forecast of forecasts) { + series.push(this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units)); + } + return series; + }, + + getHourlyForecastFrom(weatherData) { + const series = []; + + for (const forecast of weatherData.properties.timeseries) { + forecast.symbol = forecast.data.next_1_hours?.summary?.symbol_code; + forecast.precipitationAmount = forecast.data.next_1_hours?.details?.precipitation_amount; + forecast.precipitationProbability = forecast.data.next_1_hours?.details?.probability_of_precipitation; + forecast.minTemperature = forecast.data.next_1_hours?.details?.air_temperature_min; + forecast.maxTemperature = forecast.data.next_1_hours?.details?.air_temperature_max; + forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); + series.push(forecast); + } + return series; + }, + + getDailyForecastFrom(weatherData) { + const series = []; + + const days = weatherData.properties.timeseries.reduce(function (days, forecast) { + const date = moment(forecast.time).format("YYYY-MM-DD"); + days[date] = days[date] || []; + days[date].push(forecast); + return days; + }, Object.create(null)); + + Object.keys(days).forEach(function (time, index) { + let minTemperature = undefined; + let maxTemperature = undefined; + + //Default to first entry + let forecast = days[time][0]; + forecast.symbol = forecast.data.next_12_hours?.summary?.symbol_code; + forecast.precipitation = forecast.data.next_12_hours?.details?.precipitation_amount; + + //Coming days + let forecastDiffToEight = undefined; + for (const timeseries of days[time]) { + if (!timeseries.data.next_6_hours) continue; //next_6_hours has the most data + + if (!minTemperature || timeseries.data.next_6_hours.details.air_temperature_min < minTemperature) minTemperature = timeseries.data.next_6_hours.details.air_temperature_min; + if (!maxTemperature || maxTemperature < timeseries.data.next_6_hours.details.air_temperature_max) maxTemperature = timeseries.data.next_6_hours.details.air_temperature_max; + + let closestTime = Math.abs(moment(timeseries.time).local().set({ hour: 8, minute: 0, second: 0, millisecond: 0 }).diff(moment(timeseries.time).local())); + if ((forecastDiffToEight === undefined || closestTime < forecastDiffToEight) && timeseries.data.next_12_hours) { + forecastDiffToEight = closestTime; + forecast = timeseries; + } + } + const forecastXHours = forecast.data.next_12_hours ?? forecast.data.next_6_hours ?? forecast.data.next_1_hours; + if (forecastXHours) { + forecast.symbol = forecastXHours.summary?.symbol_code; + forecast.precipitationAmount = forecastXHours.details?.precipitation_amount ?? forecast.data.next_6_hours?.details?.precipitation_amount; // 6 hours is likely to have precipitation amount even if 12 hours does not + forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; + forecast.minTemperature = minTemperature; + forecast.maxTemperature = maxTemperature; + + series.push(forecast); + } + }); + for (const forecast of series) { + forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); + } + return series; + }, + + fetchWeatherForecast() { + this.getWeatherForecast("daily") + .then((forecast) => { + this.setWeatherForecast(forecast); + this.updateAvailable(); + }) + .catch((error) => { + Log.error(error); + throw new Error(error); + }); + } +}); diff --git a/mm/mounts/modules/default/weather/weather.css b/mm/mounts/modules/default/weather/weather.css new file mode 100644 index 0000000..816f0a9 --- /dev/null +++ b/mm/mounts/modules/default/weather/weather.css @@ -0,0 +1,49 @@ +.weather .weathericon, +.weather .fa-home { + font-size: 75%; + line-height: 65px; + display: inline-block; + transform: translate(0, -3px); +} + +.weather .humidity-icon { + padding-right: 4px; +} + +.weather .humidity-padding { + padding-bottom: 6px; +} + +.weather .day { + padding-left: 0; + padding-right: 25px; +} + +.weather .weather-icon { + padding-right: 30px; + text-align: center; +} + +.weather .min-temp { + padding-left: 20px; + padding-right: 0; +} + +.weather .precipitation-amount, +.weather .precipitation-prob, +.weather .uv-index { + padding-left: 20px; + padding-right: 0; +} + +.weather tr .weathericon { + line-height: 25px; +} + +.weather tr.colored .min-temp { + color: #bcddff; +} + +.weather tr.colored .max-temp { + color: #ff8e99; +} diff --git a/mm/mounts/modules/default/weather/weather.js b/mm/mounts/modules/default/weather/weather.js new file mode 100644 index 0000000..08f754c --- /dev/null +++ b/mm/mounts/modules/default/weather/weather.js @@ -0,0 +1,297 @@ +/* global WeatherProvider, WeatherUtils, formatTime */ + +/* MagicMirror² + * Module: Weather + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ +Module.register("weather", { + // Default module config. + defaults: { + weatherProvider: "openweathermap", + roundTemp: false, + type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint) + lang: config.language, + units: config.units, + tempUnits: config.units, + windUnits: config.units, + timeFormat: config.timeFormat, + updateInterval: 10 * 60 * 1000, // every 10 minutes + animationSpeed: 1000, + showFeelsLike: true, + showHumidity: false, + showIndoorHumidity: false, + showIndoorTemperature: false, + showPeriod: true, + showPeriodUpper: false, + showPrecipitationAmount: false, + showPrecipitationProbability: false, + showUVIndex: false, + showSun: true, + showWindDirection: true, + showWindDirectionAsArrow: false, + degreeLabel: false, + decimalSymbol: ".", + maxNumberOfDays: 5, + maxEntries: 5, + ignoreToday: false, + fade: true, + fadePoint: 0.25, // Start on 1/4th of the list. + initialLoadDelay: 0, // 0 seconds delay + appendLocationNameToHeader: true, + calendarClass: "calendar", + tableClass: "small", + onlyTemp: false, + colored: false, + absoluteDates: false, + hourlyForecastIncrements: 1 + }, + + // Module properties. + weatherProvider: null, + + // Can be used by the provider to display location of event if nothing else is specified + firstEvent: null, + + // Define required scripts. + getStyles: function () { + return ["font-awesome.css", "weather-icons.css", "weather.css"]; + }, + + // Return the scripts that are necessary for the weather module. + getScripts: function () { + return ["moment.js", this.file("../utils.js"), "weatherutils.js", "weatherprovider.js", "weatherobject.js", "suncalc.js", this.file(`providers/${this.config.weatherProvider.toLowerCase()}.js`)]; + }, + + // Override getHeader method. + getHeader: function () { + if (this.config.appendLocationNameToHeader && this.weatherProvider) { + if (this.data.header) return `${this.data.header} ${this.weatherProvider.fetchedLocation()}`; + else return this.weatherProvider.fetchedLocation(); + } + + return this.data.header ? this.data.header : ""; + }, + + // Start the weather module. + start: function () { + moment.locale(this.config.lang); + + if (this.config.useKmh) { + Log.warn("Your are using the deprecated config values 'useKmh'. Please switch to windUnits!"); + this.windUnits = "kmh"; + } else if (this.config.useBeaufort) { + Log.warn("Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!"); + this.windUnits = "beaufort"; + } + + // Initialize the weather provider. + this.weatherProvider = WeatherProvider.initialize(this.config.weatherProvider, this); + + // Let the weather provider know we are starting. + this.weatherProvider.start(); + + // Add custom filters + this.addFilters(); + + // Schedule the first update. + this.scheduleUpdate(this.config.initialLoadDelay); + }, + + // Override notification handler. + notificationReceived: function (notification, payload, sender) { + if (notification === "CALENDAR_EVENTS") { + const senderClasses = sender.data.classes.toLowerCase().split(" "); + if (senderClasses.indexOf(this.config.calendarClass.toLowerCase()) !== -1) { + this.firstEvent = null; + for (let event of payload) { + if (event.location || event.geo) { + this.firstEvent = event; + Log.debug("First upcoming event with location: ", event); + break; + } + } + } + } else if (notification === "INDOOR_TEMPERATURE") { + this.indoorTemperature = this.roundValue(payload); + this.updateDom(300); + } else if (notification === "INDOOR_HUMIDITY") { + this.indoorHumidity = this.roundValue(payload); + this.updateDom(300); + } + }, + + // Select the template depending on the display type. + getTemplate: function () { + switch (this.config.type.toLowerCase()) { + case "current": + return "current.njk"; + case "hourly": + return "hourly.njk"; + case "daily": + case "forecast": + return "forecast.njk"; + //Make the invalid values use the "Loading..." from forecast + default: + return "forecast.njk"; + } + }, + + // Add all the data to the template. + getTemplateData: function () { + const currentData = this.weatherProvider.currentWeather(); + const forecastData = this.weatherProvider.weatherForecast(); + + // Skip some hourly forecast entries if configured + const hourlyData = this.weatherProvider.weatherHourly()?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); + + return { + config: this.config, + current: currentData, + forecast: forecastData, + hourly: hourlyData, + indoor: { + humidity: this.indoorHumidity, + temperature: this.indoorTemperature + } + }; + }, + + // What to do when the weather provider has new information available? + updateAvailable: function () { + Log.log("New weather information available."); + this.updateDom(0); + this.scheduleUpdate(); + + if (this.weatherProvider.currentWeather()) { + this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType.replace("-", "_") }); + } + + const notificationPayload = { + currentWeather: this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null, + forecastArray: this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [], + hourlyArray: this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [], + locationName: this.weatherProvider?.fetchedLocationName, + providerName: this.weatherProvider.providerName + }; + this.sendNotification("WEATHER_UPDATED", notificationPayload); + }, + + scheduleUpdate: function (delay = null) { + let nextLoad = this.config.updateInterval; + if (delay !== null && delay >= 0) { + nextLoad = delay; + } + + setTimeout(() => { + switch (this.config.type.toLowerCase()) { + case "current": + this.weatherProvider.fetchCurrentWeather(); + break; + case "hourly": + this.weatherProvider.fetchWeatherHourly(); + break; + case "daily": + case "forecast": + this.weatherProvider.fetchWeatherForecast(); + break; + default: + Log.error(`Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`); + } + }, nextLoad); + }, + + roundValue: function (temperature) { + const decimals = this.config.roundTemp ? 0 : 1; + const roundValue = parseFloat(temperature).toFixed(decimals); + return roundValue === "-0" ? 0 : roundValue; + }, + + addFilters() { + this.nunjucksEnvironment().addFilter( + "formatTime", + function (date) { + return formatTime(this.config, date); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "unit", + function (value, type, valueUnit) { + let formattedValue; + if (type === "temperature") { + formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`; + if (this.config.degreeLabel) { + if (this.config.tempUnits === "metric") { + formattedValue += "C"; + } else if (this.config.tempUnits === "imperial") { + formattedValue += "F"; + } else { + formattedValue += "K"; + } + } + } else if (type === "precip") { + if (value === null || isNaN(value) || value === 0 || value.toFixed(2) === "0.00") { + formattedValue = ""; + } else { + formattedValue = WeatherUtils.convertPrecipitationUnit(value, valueUnit, this.config.units); + } + } else if (type === "humidity") { + formattedValue = `${value}%`; + } else if (type === "wind") { + formattedValue = WeatherUtils.convertWind(value, this.config.windUnits); + } + return formattedValue; + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "roundValue", + function (value) { + return this.roundValue(value); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "decimalSymbol", + function (value) { + return value.toString().replace(/\./g, this.config.decimalSymbol); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "calcNumSteps", + function (forecast) { + return Math.min(forecast.length, this.config.maxNumberOfDays); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "calcNumEntries", + function (dataArray) { + return Math.min(dataArray.length, this.config.maxEntries); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "opacity", + function (currentStep, numSteps) { + if (this.config.fade && this.config.fadePoint < 1) { + if (this.config.fadePoint < 0) { + this.config.fadePoint = 0; + } + const startingPoint = numSteps * this.config.fadePoint; + const numFadesteps = numSteps - startingPoint; + if (currentStep >= startingPoint) { + return 1 - (currentStep - startingPoint) / numFadesteps; + } else { + return 1; + } + } else { + return 1; + } + }.bind(this) + ); + } +}); diff --git a/mm/mounts/modules/default/weather/weatherobject.js b/mm/mounts/modules/default/weather/weatherobject.js new file mode 100644 index 0000000..9fae887 --- /dev/null +++ b/mm/mounts/modules/default/weather/weatherobject.js @@ -0,0 +1,136 @@ +/* global SunCalc, WeatherUtils */ + +/* MagicMirror² + * Module: Weather + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + * + * This class is the blueprint for a day which includes weather information. + * + * Currently this is focused on the information which is necessary for the current weather. + * As soon as we start implementing the forecast, mode properties will be added. + */ + +/** + * @external Moment + */ +class WeatherObject { + /** + * Constructor for a WeatherObject + */ + constructor() { + this.date = null; + this.windSpeed = null; + this.windFromDirection = null; + this.sunrise = null; + this.sunset = null; + this.temperature = null; + this.minTemperature = null; + this.maxTemperature = null; + this.weatherType = null; + this.humidity = null; + this.precipitationAmount = null; + this.precipitationUnits = null; + this.precipitationProbability = null; + this.feelsLikeTemp = null; + } + + cardinalWindDirection() { + if (this.windFromDirection > 11.25 && this.windFromDirection <= 33.75) { + return "NNE"; + } else if (this.windFromDirection > 33.75 && this.windFromDirection <= 56.25) { + return "NE"; + } else if (this.windFromDirection > 56.25 && this.windFromDirection <= 78.75) { + return "ENE"; + } else if (this.windFromDirection > 78.75 && this.windFromDirection <= 101.25) { + return "E"; + } else if (this.windFromDirection > 101.25 && this.windFromDirection <= 123.75) { + return "ESE"; + } else if (this.windFromDirection > 123.75 && this.windFromDirection <= 146.25) { + return "SE"; + } else if (this.windFromDirection > 146.25 && this.windFromDirection <= 168.75) { + return "SSE"; + } else if (this.windFromDirection > 168.75 && this.windFromDirection <= 191.25) { + return "S"; + } else if (this.windFromDirection > 191.25 && this.windFromDirection <= 213.75) { + return "SSW"; + } else if (this.windFromDirection > 213.75 && this.windFromDirection <= 236.25) { + return "SW"; + } else if (this.windFromDirection > 236.25 && this.windFromDirection <= 258.75) { + return "WSW"; + } else if (this.windFromDirection > 258.75 && this.windFromDirection <= 281.25) { + return "W"; + } else if (this.windFromDirection > 281.25 && this.windFromDirection <= 303.75) { + return "WNW"; + } else if (this.windFromDirection > 303.75 && this.windFromDirection <= 326.25) { + return "NW"; + } else if (this.windFromDirection > 326.25 && this.windFromDirection <= 348.75) { + return "NNW"; + } else { + return "N"; + } + } + + /** + * Determines if the sun sets or rises next. Uses the current time and not + * the date from the weather-forecast. + * @param {Moment} date an optional date where you want to get the next + * action for. Useful only in tests, defaults to the current time. + * @returns {string} "sunset" or "sunrise" + */ + nextSunAction(date = moment()) { + return date.isBetween(this.sunrise, this.sunset) ? "sunset" : "sunrise"; + } + + feelsLike() { + if (this.feelsLikeTemp) { + return this.feelsLikeTemp; + } + return WeatherUtils.calculateFeelsLike(this.temperature, this.windSpeed, this.humidity); + } + + /** + * Checks if the weatherObject is at dayTime. + * @returns {boolean} true if it is at dayTime + */ + isDayTime() { + const now = !this.date ? moment() : this.date; + return now.isBetween(this.sunrise, this.sunset, undefined, "[]"); + } + + /** + * Update the sunrise / sunset time depending on the location. This can be + * used if your provider doesn't provide that data by itself. Then SunCalc + * is used here to calculate them according to the location. + * @param {number} lat latitude + * @param {number} lon longitude + */ + updateSunTime(lat, lon) { + const now = !this.date ? new Date() : this.date.toDate(); + const times = SunCalc.getTimes(now, lat, lon); + this.sunrise = moment(times.sunrise); + this.sunset = moment(times.sunset); + } + + /** + * Clone to simple object to prevent mutating and deprecation of legacy library. + * + * Before being handed to other modules, mutable values must be cloned safely. + * Especially 'moment' object is not immutable, so original 'date', 'sunrise', 'sunset' could be corrupted or changed by other modules. + * @returns {object} plained object clone of original weatherObject + */ + simpleClone() { + const toFlat = ["date", "sunrise", "sunset"]; + let clone = { ...this }; + for (const prop of toFlat) { + clone[prop] = clone?.[prop]?.valueOf() ?? clone?.[prop]; + } + return clone; + } +} + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = WeatherObject; +} diff --git a/mm/mounts/modules/default/weather/weatherprovider.js b/mm/mounts/modules/default/weather/weatherprovider.js new file mode 100644 index 0000000..662f87b --- /dev/null +++ b/mm/mounts/modules/default/weather/weatherprovider.js @@ -0,0 +1,168 @@ +/* global Class, performWebRequest */ + +/* MagicMirror² + * Module: Weather + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + * + * This class is the blueprint for a weather provider. + */ +const WeatherProvider = Class.extend({ + // Weather Provider Properties + providerName: null, + defaults: {}, + + // The following properties have accessor methods. + // Try to not access them directly. + currentWeatherObject: null, + weatherForecastArray: null, + weatherHourlyArray: null, + fetchedLocationName: null, + + // The following properties will be set automatically. + // You do not need to overwrite these properties. + config: null, + delegate: null, + providerIdentifier: null, + + // Weather Provider Methods + // All the following methods can be overwritten, although most are good as they are. + + // Called when a weather provider is initialized. + init: function (config) { + this.config = config; + Log.info(`Weather provider: ${this.providerName} initialized.`); + }, + + // Called to set the config, this config is the same as the weather module's config. + setConfig: function (config) { + this.config = config; + Log.info(`Weather provider: ${this.providerName} config set.`, this.config); + }, + + // Called when the weather provider is about to start. + start: function () { + Log.info(`Weather provider: ${this.providerName} started.`); + }, + + // This method should start the API request to fetch the current weather. + // This method should definitely be overwritten in the provider. + fetchCurrentWeather: function () { + Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchCurrentWeather method.`); + }, + + // This method should start the API request to fetch the weather forecast. + // This method should definitely be overwritten in the provider. + fetchWeatherForecast: function () { + Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchWeatherForecast method.`); + }, + + // This method should start the API request to fetch the weather hourly. + // This method should definitely be overwritten in the provider. + fetchWeatherHourly: function () { + Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchWeatherHourly method.`); + }, + + // This returns a WeatherDay object for the current weather. + currentWeather: function () { + return this.currentWeatherObject; + }, + + // This returns an array of WeatherDay objects for the weather forecast. + weatherForecast: function () { + return this.weatherForecastArray; + }, + + // This returns an object containing WeatherDay object(s) depending on the type of call. + weatherHourly: function () { + return this.weatherHourlyArray; + }, + + // This returns the name of the fetched location or an empty string. + fetchedLocation: function () { + return this.fetchedLocationName || ""; + }, + + // Set the currentWeather and notify the delegate that new information is available. + setCurrentWeather: function (currentWeatherObject) { + // We should check here if we are passing a WeatherDay + this.currentWeatherObject = currentWeatherObject; + }, + + // Set the weatherForecastArray and notify the delegate that new information is available. + setWeatherForecast: function (weatherForecastArray) { + // We should check here if we are passing a WeatherDay + this.weatherForecastArray = weatherForecastArray; + }, + + // Set the weatherHourlyArray and notify the delegate that new information is available. + setWeatherHourly: function (weatherHourlyArray) { + this.weatherHourlyArray = weatherHourlyArray; + }, + + // Set the fetched location name. + setFetchedLocation: function (name) { + this.fetchedLocationName = name; + }, + + // Notify the delegate that new weather is available. + updateAvailable: function () { + this.delegate.updateAvailable(this); + }, + + /** + * A convenience function to make requests. + * @param {string} url the url to fetch from + * @param {string} type what contenttype to expect in the response, can be "json" or "xml" + * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send + * @param {Array.} expectedResponseHeaders the expected HTTP headers to recieve + * @returns {Promise} resolved when the fetch is done + */ + fetchData: async function (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) { + const mockData = this.config.mockData; + if (mockData) { + const data = mockData.substring(1, mockData.length - 1); + return JSON.parse(data); + } + const useCorsProxy = typeof this.config.useCorsProxy !== "undefined" && this.config.useCorsProxy; + return performWebRequest(url, type, useCorsProxy, requestHeaders, expectedResponseHeaders); + } +}); + +/** + * Collection of registered weather providers. + */ +WeatherProvider.providers = []; + +/** + * Static method to register a new weather provider. + * @param {string} providerIdentifier The name of the weather provider + * @param {object} providerDetails The details of the weather provider + */ +WeatherProvider.register = function (providerIdentifier, providerDetails) { + WeatherProvider.providers[providerIdentifier.toLowerCase()] = WeatherProvider.extend(providerDetails); +}; + +/** + * Static method to initialize a new weather provider. + * @param {string} providerIdentifier The name of the weather provider + * @param {object} delegate The weather module + * @returns {object} The new weather provider + */ +WeatherProvider.initialize = function (providerIdentifier, delegate) { + const pi = providerIdentifier.toLowerCase(); + + const provider = new WeatherProvider.providers[pi](); + const config = Object.assign({}, provider.defaults, delegate.config); + + provider.delegate = delegate; + provider.setConfig(config); + + provider.providerIdentifier = pi; + if (!provider.providerName) { + provider.providerName = pi; + } + + return provider; +}; diff --git a/mm/mounts/modules/default/weather/weatherutils.js b/mm/mounts/modules/default/weather/weatherutils.js new file mode 100644 index 0000000..47fea33 --- /dev/null +++ b/mm/mounts/modules/default/weather/weatherutils.js @@ -0,0 +1,142 @@ +/* MagicMirror² + * Weather Util Methods + * + * By Rejas + * MIT Licensed. + */ +const WeatherUtils = { + /** + * Convert wind (from m/s) to beaufort scale + * @param {number} speedInMS the windspeed you want to convert + * @returns {number} the speed in beaufort + */ + beaufortWindSpeed(speedInMS) { + const windInKmh = this.convertWind(speedInMS, "kmh"); + const speeds = [1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117, 1000]; + for (const [index, speed] of speeds.entries()) { + if (speed > windInKmh) { + return index; + } + } + return 12; + }, + + /** + * Convert a value in a given unit to a string with a converted + * value and a postfix matching the output unit system. + * @param {number} value - The value to convert. + * @param {string} valueUnit - The unit the values has. Default is mm. + * @param {string} outputUnit - The unit system (imperial/metric) the return value should have. + * @returns {string} - A string with tha value and a unit postfix. + */ + convertPrecipitationUnit(value, valueUnit, outputUnit) { + if (valueUnit === "%") return `${value.toFixed(0)} ${valueUnit}`; + + let convertedValue = value; + let conversionUnit = valueUnit; + if (outputUnit === "imperial") { + if (valueUnit && valueUnit.toLowerCase() === "cm") convertedValue = convertedValue * 0.3937007874; + else convertedValue = convertedValue * 0.03937007874; + conversionUnit = "in"; + } else { + conversionUnit = valueUnit ? valueUnit : "mm"; + } + + return `${convertedValue.toFixed(2)} ${conversionUnit}`; + }, + + /** + * Convert temp (from degrees C) into imperial or metric unit depending on + * your config + * @param {number} tempInC the temperature in celsius you want to convert + * @param {string} unit can be 'imperial' or 'metric' + * @returns {number} the converted temperature + */ + convertTemp(tempInC, unit) { + return unit === "imperial" ? tempInC * 1.8 + 32 : tempInC; + }, + + /** + * Convert wind speed into another unit. + * @param {number} windInMS the windspeed in meter/sec you want to convert + * @param {string} unit can be 'beaufort', 'kmh', 'knots, 'imperial' (mph) + * or 'metric' (mps) + * @returns {number} the converted windspeed + */ + convertWind(windInMS, unit) { + switch (unit) { + case "beaufort": + return this.beaufortWindSpeed(windInMS); + case "kmh": + return (windInMS * 3600) / 1000; + case "knots": + return windInMS * 1.943844; + case "imperial": + return windInMS * 2.2369362920544; + case "metric": + default: + return windInMS; + } + }, + + /* + * Convert the wind direction cardinal to value + */ + convertWindDirection(windDirection) { + const windCardinals = { + N: 0, + NNE: 22, + NE: 45, + ENE: 67, + E: 90, + ESE: 112, + SE: 135, + SSE: 157, + S: 180, + SSW: 202, + SW: 225, + WSW: 247, + W: 270, + WNW: 292, + NW: 315, + NNW: 337 + }; + + return windCardinals.hasOwnProperty(windDirection) ? windCardinals[windDirection] : null; + }, + + convertWindToMetric(mph) { + return mph / 2.2369362920544; + }, + + convertWindToMs(kmh) { + return kmh * 0.27777777777778; + }, + + calculateFeelsLike(temperature, windSpeed, humidity) { + const windInMph = this.convertWind(windSpeed, "imperial"); + const tempInF = this.convertTemp(temperature, "imperial"); + let feelsLike = tempInF; + + if (windInMph > 3 && tempInF < 50) { + feelsLike = Math.round(35.74 + 0.6215 * tempInF - 35.75 * Math.pow(windInMph, 0.16) + 0.4275 * tempInF * Math.pow(windInMph, 0.16)); + } else if (tempInF > 80 && humidity > 40) { + feelsLike = + -42.379 + + 2.04901523 * tempInF + + 10.14333127 * humidity - + 0.22475541 * tempInF * humidity - + 6.83783 * Math.pow(10, -3) * tempInF * tempInF - + 5.481717 * Math.pow(10, -2) * humidity * humidity + + 1.22874 * Math.pow(10, -3) * tempInF * tempInF * humidity + + 8.5282 * Math.pow(10, -4) * tempInF * humidity * humidity - + 1.99 * Math.pow(10, -6) * tempInF * tempInF * humidity * humidity; + } + + return ((feelsLike - 32) * 5) / 9; + } +}; + +if (typeof module !== "undefined") { + module.exports = WeatherUtils; +} diff --git a/npm/data/database.sqlite b/npm/data/database.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0852eb019406bc139bf5b841f55d43bb966206cb GIT binary patch literal 102400 zcmeI5+ix3JdcZlRD3X#T+VQH~z$iRo?ATo0W_S@LZnClCs91Ls=j!byfiWC$4#kPc z8G2?YN^7VEE!lCf-Rz=I-OVnF0$mgZ`m!hr6zyYQx5z_Ziv9z|KDFpeQKS$3&KV9l zGn}ENtQ6VGN9Zy<^Ub;Z&Ufx}zVq(eud5c}Yr4^tEj}MP7vZ?bb)JtzBA4O!dH8ic zS-2Q;zQBLZbsctjIkH&$ZHx-1zMwMaQeRB{d}?g+HxoA!a{Tky+L{06zQZktJ^mEC zCurRE#Iy1ArAyo+(UR92L?N;w)pgVAUrgP6XYIy&Yy5jRUVVLyuk_t3{I!azRCrah z$ObWZO}BWh-DvPFLv6~&4!=ouc)4xqs#Y~flW5jFUx6#xB8sGI6<&cWOKp<=lW@PO zD{4(0A)t3OVn}Th{saXMUZ|#|kOqNJ0^+8Sn%r(!{8iy9h$?zhRy9eJo5ZZ}ADO!5 zj!Tso7DT2B(F_cW_j+cU4GDCxdVp|cWlPpTP^#)$P2H&Q7TE@P4#2*@G>%PT$-cN{ z_ura~rxzBu$M3Q{Gl-%Zq-v?U#uU*9<=<7M9>yo)>DgKC zllPfQwG4fGhbhzGYSODlgBxEP1yWn#dgCZrOE;|H+ycdtt5sr}QbRSZgH$KSr&(3G zT359ViD<0CAC9cAV*83Uv71W9)3@fhZb{V?vdyac7BQNtY0?U=SM^J6O}*D9zPl4W z6}@x&*4ua1_*=JMTl)cD@d^37sPItj`(>y~_d=><_wFX*>A5-X(LGi+eBSxKCcUM^ zclTt9ij$I|N(9t$OEuMXwV_%&;krF2>QE7dRh<52WrS$I4>3Z8(4$-5bqN;hYa!sJ~(+JN`+AA7ySFLW2mXNn( zOE$uNF!aVyuPghYIL@+S{|%NE(0#1a-?%p`sME=0#i){G?X+CL@`9&ZVG`*2Lc)x?#w)KM3s&bR&FU$GtRtcNth(^9D2wqq_DGR~nY9 z`u#|4 zZfay(>I>V+3vwItrXfh=2%)}pW}G%gc0bL$2Q@d5Oag5oj3|6xqn?QQZeF9X=Hba~ z_a%-g&x56)Wc!kH#^YzjclRsKH5k99Ca35y_8k3#A0&VTkN^@u0!RP}AOR$R1dsp{ zKmthMv=X4>|B2KGk<@>r{x$W_seerUI`zxcr>Q?l>EMVTB!C2v01`j~NB{{S0VIF~ zkN^@u0!ZKq1TIWO7n1(>x~7xS7m}{ms-_ar+2r62v8j0U5+}D6)q>YSHj>YcN9U5h zm*C>D=uDEm)-whd^yYLlI?D~-v^e+N8F*^&oxNC+=Ay-Ga*{Ytxab&!>Ji)dT_jAOR$R1dsp{Kmter2_OL^fCRpt1PbS35h)qV2{};| zg%z<-%!vib09#&QVT;KPbfR)7{iGPzG4|L^MGeJd4HVzE#X@({wnS``@Fu-Yb=!BU9x)ZZ>m#UgilAvjOvyiZl@U{B0> z;BQ_$8;jgz4+s!QK`i7~3L=^>-=Oaa@Up<3RDv01j%JaM5;DaFjFSg z)NSXXzgn7bVk0;Y_v_I%1#ACSs9Q zM@(E1mTBCjx@q-A{(L?ji@ezvDT-nda7)#;Y3WU{v|!tJa6J6!mGMEiqELW*0p5LM zAdc9N=VGzQ8_d%cF(>8)F%Q{K?UpeMS*C~Vqi>D%GFw~$9mxZLLUBc^TUIM4ne7%e zb>w)wpN+;Mw|XAav=?*v!gAkZYEjM+{o|`=Vv#jlR9MY{XrUZEa2X(p#g(EUshVo3a>MbijqCqk&k77vjRcSY5|ns2_OL^fCP{L517q^c{_kZYY;?>zBp+<(m!T)~mNWw~c$X#>(>BugM#oweP;! zdjGAhV&~5Coey4L{9bOSRWRTFgY`U7x8Hd0){4-)ziY4m*CMIfF((kcM*>Iy2_OL^ zfCP{L5|yoL0lH9IYB52N;O}s38k9&N^euj z%gyG88ChwnTIQ}j{{K}Z^{Z2#379}6fCP{L5utlHGd=-?sXKSS~Jmm(mM`a-r~H296paU~pet7VAG;v=3*<+!af?MR8?O zSS+Oe@_wcPhW3e8HFjFvm$`4c15EY;Jb#ezie+*2kl|U9ykQWMDc9tNN#-+(1}8w& zVX>b;toyFohN^8#y4FxNaI}mzNZM}1FfwJW-DvF6lL$WSz5jnRlDc_%RRU9n1dsp{ zKmter2_OL^fCP{L5;lM_5aAZBcOm@AOR$R1dsp{Kmter2_OL^fCP{L5o zznQp^kmH|^r6Qkk-{F>zEU3eJ^yuDXJiV~M{dmTb*BitjifWLmrRthQ-HFVX2L=EMR*JoMTk~Q#G zs_I%z-Kg*u*@kR%va&BsX*G!@4^yJMUUHJzZnbp7dSZA)qnbA|XG9uS-Ly>U0KzOA znXuVjw=?R=iFPv9eq|>lPX}4hd$or~%XyL}8jvYuRD$zD?rQeqmnY)s*;($>HCE+r_e zffe^FFRZYwP47q@vTi~xK`g1=f@5z+Yq4Fz4pqpV3_D=?qJ@bTt^*WzRyWYs`)H%p ztFn)^w#ar<$$0wK9M>(WnnJc&!_y*0Q#DQ6ZuS}?sjaE^+QfHvqF1o*+`jepoi+Z} z?bp_Rz*l@iJ})XfG(vtES`qX@s$}=>CgSP2IquOtR-^d5^LlWYLlc`{wlnk{^ zkds@gsjjOH)!GTEmO*AvL4pQ^wa5P6$_P<~$O@~HN66T_R!0c9>Yx!)uBsX00A8Op-XfyG#D0TyP0S_T`Y3l4Hn-`jciMOVLN$2 zZe!jw1c@9W)VI!z)5hq17De+O)Z9ce3ABYUs_=b{dLrh#d5yxFhbOb$mpG<850-+G z?Mu!XkDnFa-IK_*sp(`S$^9e}uW>(#{^%^3d@=bi;~&IJv6# zGwz}?Y@1AIJJ9RNkMgWyRa^t{+WL|F(7NQ-4C9Nr!QXQ_9pGdmRJ@PIMe<&lN<%}0Zh`Dd+NL(GFrh8<#R`Rg}V zoPF^9^Gwb5@7pa%TTv|-S8T}6#W%ca)^|S{H5)Y^)Ym_PwI0bkEc8@6gSws0#`WZb z>bQX*YXoGrgcM#w1l45^UOLB?qW0(fO*3mw+_fxiUM~1ByC!^3ZuISrWDq8RNHfG+ z$9jl^1Z_b(x&bR9VRdv6zP;HL>)AX?GJLKw@49@>3(a-sWVG{0m+oK^a7$H)5zb9h ztAs7>g@_Ean5z5eN)^`AV5!s$U$z|XNoC;}xc^}zkDDxyaOBYd%+At)+6u{)fy^^H z=)m{?4?W9)4~fCP{L5@|ki$ z5FozZ-CUWn07>vz>0^Npwo7KpHMwCDkn$S+>brZ5du7V@oRM!6C8V2zfbBz>vUfg8 z-Y^L1dlop=1d$H5=j8!DBR_NuTnoEUJsz-HI!5B&rC(R%XTj)D2u!Mf|Hn_c$r|E=YlSzH78F~{s-^;5) z4*uj;<};SgzL!>4bE~=1?k=dnaM9`8IS7f*?#>yOPczAj-J~y5gmS^(NKhwgwxVD3 zsr?G@ny!mOY#n2wysQstIx$;KksP+PJu zmjqHO2qmzNR9#*#3F5L)%?UzLP^$T2O(@mGS9%uCV2JUA6d*YSnpJvW%tZ|eu{|5qeu~~up|64Y^o_Ynfqv2abUE7VNUTYg}*BST! zA4PMF`~QCj%i_5I-*4-kHg(5Z=G|3`|1US28D{^Vn|>_aIF*cO+;SqT@7ZZUC+Y=cCf)iXC|B4poE1@1?&ap1z1C983Zf| zZV}10{A|yzy5>=&-daP@9L~YkKb0+6s}k>Os^3;;Z*tgYAk%R*EmP`S#B@8F?R7it RV4Sh%9$<*pZ;x^J{{f$ol)(T1 literal 0 HcmV?d00001 diff --git a/npm/data/keys.json b/npm/data/keys.json new file mode 100644 index 0000000..8e6628c --- /dev/null +++ b/npm/data/keys.json @@ -0,0 +1,4 @@ +{ + "key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAlJJbPtCQVok6fimzbpiatLaJVucnmjdbkMT9RGKA580Zuu2n\n9caQBFqmHpPirdSjYQ1XwCyTXS2PG9EsO8aTza3xoQTscWDUDeOv/817t+WbvZ+Y\njOoe5JQNwyuKVOdM0i982NzfIEU7EzOlCIOfFK7JzxZI3SWCSqaQAAHucj/Vn+/2\nGC3BB2U7od2hbW4mGgStHMjIagp/4Rq1NcfjcvUhqt0rWnkylhNaXwH7UqVGuu+s\nZlImv8e48q2O/Le7RPk/eBHKBeylkl+1KA0+jZK5IeV1Mf/yn4wgfNIlcBuT5AZA\nw1L2UoZkj0njXlxsASYm5oP5crWI5PlGGeZODQIDAQABAoIBAChAGl5DLMd0+BbT\n/1uYFlmdFkon56+9DZ6D78WGFjw2caEV36oTOFMlp9mi4QeNExIpBjv3F5lnzVyQ\n57XuD37qgArKhbAVtn1o0PaxWbIxj2bpBPAwQVxVzACzHA/ydfp/iQhLlltsxhtm\no3BSEFgPHndvJyBamBjXVS3bWBk1S99/JfGtPoUlPpVI9QK3yMwOd02xve+0orZm\nk5pvzwMI/dRR0Do5JdJenTTB6haYcuUomGzGs2JayEhnrGfearqcTziU+97diEjT\nadVUOggKrBoBciEkmT5XaHDenEOyWD/4Q+rpCQQ6AUyqXNbiFWJVxY2gj4QsRpab\n/KFdpsECgYEA8nxbOqujnAz0ofMwAVdy/DRhTHTNqr/6kHl3kcf+fpG9C9wmDnqZ\nWRAD3Mzn8D6GSX2icV0ainuJkKxavOQn3/agAadNBd+zEOfr9Y+mvv2rrfhGAu/Z\niJ7okdYDfzzZfOmSUlPmCZ86l9cDmKIriCbqlPf3vPcvf0OPi0qOsnECgYEAnNoW\n98SPyyyl+z6Ye8ybw3wiad4FTPcBYYMZrDAZOQGwfu2XI58wDxwUFOqDmmfBP2Wm\n5APhbcuHaIyvsVuyaLyItaJ0Gk1HXNUT13YriTRHAspD/O1CDHiBM352riA+7tF/\nOQFMQN8kca+zUTDo3aziZLWxsYG2TLipZfO+q10CgYEAtYwLlaqqDQzaH2J+35JE\nobTp4B9XWG7xvzdiIUB2NvcQbg++YnrB0x+ddLPpN0LosZ8hfvSxCVuizDFuohvE\nCveQJozGqw4n+BFb5XdO5ZHw9oh6inpfCN3IzF9KMPoy70XE6mSsRyny6Xnu1Fke\ndIqGeVAKKG19HzBLCYOhwNECgYBVGFCrnP0yCggGlYAecfPzi04UR3pytfMO1xQ/\ngVy9u7foNLfxgHSPTflrG4vIYg/KeDihraVIbsoIo4LR4uCYx+gXOopolpJnv8Xi\n995Iso7v6ZIXDxTtNRdLO4nhj+b0o720zIp1C1p3Pw42tyUu1pOAdb5wgeHIH8rv\nX9yKlQKBgQCqJe9cA5NoLxNFc7aLDU31w2GYDU3ms8zdgk93kLpoNSkeotl9HUup\nrw0YIGzo6kIfUb7pII6mEtti3T25bTYdY6OZ6E4J92oEK5Y2Ax/5pNbW0o3bGy1H\n6ffopsWzsLfGqbowB3z5IlXuUTocbDoh9fU6QAZEI7EbekuKoD7oeg==\n-----END RSA PRIVATE KEY-----", + "pub": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlJJbPtCQVok6fimzbpia\ntLaJVucnmjdbkMT9RGKA580Zuu2n9caQBFqmHpPirdSjYQ1XwCyTXS2PG9EsO8aT\nza3xoQTscWDUDeOv/817t+WbvZ+YjOoe5JQNwyuKVOdM0i982NzfIEU7EzOlCIOf\nFK7JzxZI3SWCSqaQAAHucj/Vn+/2GC3BB2U7od2hbW4mGgStHMjIagp/4Rq1Ncfj\ncvUhqt0rWnkylhNaXwH7UqVGuu+sZlImv8e48q2O/Le7RPk/eBHKBeylkl+1KA0+\njZK5IeV1Mf/yn4wgfNIlcBuT5AZAw1L2UoZkj0njXlxsASYm5oP5crWI5PlGGeZO\nDQIDAQAB\n-----END PUBLIC KEY-----" +} \ No newline at end of file diff --git a/npm/data/nginx/proxy_host/1.conf b/npm/data/nginx/proxy_host/1.conf new file mode 100644 index 0000000..ca33161 --- /dev/null +++ b/npm/data/nginx/proxy_host/1.conf @@ -0,0 +1,78 @@ +# ------------------------------------------------------------ +# ingest.161hz.stream +# ------------------------------------------------------------ + + +server { + set $forward_scheme http; + set $server "172.18.0.4"; + set $port 4001; + + listen 80; +listen [::]:80; + +listen 443 ssl http2; +listen [::]:443 ssl http2; + + + server_name ingest.161hz.stream; + + + # Let's Encrypt SSL + include conf.d/include/letsencrypt-acme-challenge.conf; + include conf.d/include/ssl-ciphers.conf; + ssl_certificate /etc/letsencrypt/live/npm-1/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/npm-1/privkey.pem; + + + + + + + # Block Exploits + include conf.d/include/block-exploits.conf; + + + + + + + + +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $http_connection; +proxy_http_version 1.1; + + + access_log /data/logs/proxy-host-1_access.log proxy; + error_log /data/logs/proxy-host-1_error.log warn; + + + + + + + + location / { + + + + + + + + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + proxy_http_version 1.1; + + + # Proxy! + include conf.d/include/proxy.conf; + } + + + # Custom + include /data/nginx/custom/server_proxy[.]conf; +} + diff --git a/npm/data/nginx/redirection_host/1.conf b/npm/data/nginx/redirection_host/1.conf new file mode 100644 index 0000000..5fe83a8 --- /dev/null +++ b/npm/data/nginx/redirection_host/1.conf @@ -0,0 +1,41 @@ +# ------------------------------------------------------------ +# localhost +# ------------------------------------------------------------ + + +server { + listen 80; +listen [::]:80; + + + server_name localhost; + + + + + + + + + + + access_log /data/logs/redirection-host-1_access.log standard; + error_log /data/logs/redirection-host-1_error.log warn; + + + + + location / { + + + + + return 300 $scheme://ingest.161hz.stream$request_uri; + + } + + + # Custom + include /data/nginx/custom/server_redirect[.]conf; +} + diff --git a/npm/docker-compose.yml.txt b/npm/docker-compose.yml.txt new file mode 100644 index 0000000..032eece --- /dev/null +++ b/npm/docker-compose.yml.txt @@ -0,0 +1,19 @@ +version: '3.8' +services: + app: + image: 'jc21/nginx-proxy-manager:latest' + restart: unless-stopped + ports: + - '80:80' + - '8082:81' + - '443:443' + volumes: + - ./data:/data + - ./letsencrypt:/etc/letsencrypt + + networks: + - proxy + +networks: + proxy: + external: true \ No newline at end of file diff --git a/ontime/docker-compose.yml b/ontime/docker-compose.yml new file mode 100644 index 0000000..4290e2d --- /dev/null +++ b/ontime/docker-compose.yml @@ -0,0 +1,23 @@ + +version: "3.8" + +services: + ontime: + container_name: ontime + image: getontime/ontime:latest + ports: + - "4001:4001/tcp" + - "127.0.0.1:8888:8888/udp" + volumes: + - "./ontime-db:/server/preloaded-db" + - "./ontime-styles/override.css:/external/styles/override.css" + environment: + - TZ=Europe/Berlin + restart: unless-stopped + + networks: + - proxy + +networks: + proxy: + external: true diff --git a/ontime/ontime-styles/override.css b/ontime/ontime-styles/override.css new file mode 100644 index 0000000..3f40676 --- /dev/null +++ b/ontime/ontime-styles/override.css @@ -0,0 +1,23 @@ +:root { + --background-color-override: #00324D; + --color-override: #eee; + --secondary-color-override: #eee; + --accent-color-override: #FF99CC; + --label-color-override: #eee; + --timer-color-override: #202020; + --card-background-color-override: #084263; + --font-family-override: "Montserrat", sans-serif; + --font-family-bold-override: "Montserrat", sans-serif; + --timer-progress-bg-override: #eee; + --timer-progress-override: #eee; +} + +.timer { + color: black !important; +} + + +// Overriding font for timers +.timer { + font-family: "Comic Sans MS", serif !important; +} diff --git a/resteamer/docker-compose.yml b/resteamer/docker-compose.yml new file mode 100644 index 0000000..8ff4341 --- /dev/null +++ b/resteamer/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' +services: + restreamer: + container_name: restreamer1 + privileged: true + volumes: + - '/opt/core/config:/core/config' + - '/opt/core/data:/core/data' + ports: + - '8282:8080' + - '8181:8181' + - '1935:1935' + - '1936:1936' + - '6000:6000/udp' + image: 'datarhei/restreamer:latest' + + networks: + - proxy + +networks: + proxy: + external: true diff --git a/rssbridge/docker-compose.yaml b/rssbridge/docker-compose.yaml new file mode 100644 index 0000000..ec7d490 --- /dev/null +++ b/rssbridge/docker-compose.yaml @@ -0,0 +1,16 @@ +version: '3.8' +services: + rss-bridge: + image: rssbridge/rss-bridge:latest + volumes: + - ~/rssbridge/data:/config + ports: + - 3000:80 + restart: unless-stopped + + networks: + - proxy + +networks: + proxy: + external: true \ No newline at end of file