diff --git a/.gitignore b/.gitignore
index e1365c2fe6..9111914b5c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,3 +81,4 @@ yarn.lock
# Vitest temporary test files
tests/**/.tmp/
+core
diff --git a/css/icon/clear-day.svg b/css/icon/clear-day.svg
new file mode 100644
index 0000000000..5bd98463da
--- /dev/null
+++ b/css/icon/clear-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/clear-night.svg b/css/icon/clear-night.svg
new file mode 100644
index 0000000000..529e18ae0f
--- /dev/null
+++ b/css/icon/clear-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/cloudy.svg b/css/icon/cloudy.svg
new file mode 100644
index 0000000000..63f0b56732
--- /dev/null
+++ b/css/icon/cloudy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/fog.svg b/css/icon/fog.svg
new file mode 100644
index 0000000000..124ae6b132
--- /dev/null
+++ b/css/icon/fog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/hail.svg b/css/icon/hail.svg
new file mode 100644
index 0000000000..68f0bb6c7e
--- /dev/null
+++ b/css/icon/hail.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/hot-day.svg b/css/icon/hot-day.svg
new file mode 100644
index 0000000000..c2aa90734c
--- /dev/null
+++ b/css/icon/hot-day.svg
@@ -0,0 +1 @@
+
diff --git a/css/icon/i-rain.svg b/css/icon/i-rain.svg
new file mode 100644
index 0000000000..456d69564c
--- /dev/null
+++ b/css/icon/i-rain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/i-snow.svg b/css/icon/i-snow.svg
new file mode 100644
index 0000000000..8bced52158
--- /dev/null
+++ b/css/icon/i-snow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/i-wind.svg b/css/icon/i-wind.svg
new file mode 100644
index 0000000000..ff2fc3f6f4
--- /dev/null
+++ b/css/icon/i-wind.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/partly-cloudy-day.svg b/css/icon/partly-cloudy-day.svg
new file mode 100644
index 0000000000..dd40d02a9d
--- /dev/null
+++ b/css/icon/partly-cloudy-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/partly-cloudy-night.svg b/css/icon/partly-cloudy-night.svg
new file mode 100644
index 0000000000..bdbe5f7413
--- /dev/null
+++ b/css/icon/partly-cloudy-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/rain.svg b/css/icon/rain.svg
new file mode 100644
index 0000000000..f674ec91ba
--- /dev/null
+++ b/css/icon/rain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/sleet.svg b/css/icon/sleet.svg
new file mode 100644
index 0000000000..f37f877c6b
--- /dev/null
+++ b/css/icon/sleet.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/css/icon/snow.svg b/css/icon/snow.svg
new file mode 100644
index 0000000000..e383f3015c
--- /dev/null
+++ b/css/icon/snow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/thunderstorm.svg b/css/icon/thunderstorm.svg
new file mode 100644
index 0000000000..ce9b1f924c
--- /dev/null
+++ b/css/icon/thunderstorm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/tornado.svg b/css/icon/tornado.svg
new file mode 100644
index 0000000000..38b77c94e2
--- /dev/null
+++ b/css/icon/tornado.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/css/icon/wind.svg b/css/icon/wind.svg
new file mode 100644
index 0000000000..25a6f3ed1b
--- /dev/null
+++ b/css/icon/wind.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/defaultmodules/newsfeed/newsfeed.js b/defaultmodules/newsfeed/newsfeed.js
index 9a12a4b8ed..d60ec87cfc 100644
--- a/defaultmodules/newsfeed/newsfeed.js
+++ b/defaultmodules/newsfeed/newsfeed.js
@@ -270,6 +270,8 @@ Module.register("newsfeed", {
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") {
diff --git a/defaultmodules/weather/current.njk.wip b/defaultmodules/weather/current.njk.wip
new file mode 100644
index 0000000000..6e29aa2611
--- /dev/null
+++ b/defaultmodules/weather/current.njk.wip
@@ -0,0 +1,104 @@
+{% macro humidity() %}
+ {% if current.humidity %}
+ {{ current.humidity | decimalSymbol }}
+ {% endif %}
+{% endmacro %}
+{% 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 === "wind" %}
+ {{ humidity() }}
+ {% 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 %}
+
+ {% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}
+
+
+ {% 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 %}
+
+ {% endif %}
+
+ {% if current.weatherType %}
+
+ {{ current.iconHTML | safe }}
+
+ {% endif %}
+
+
+ {{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}
+
+
+ {% if config.showHumidity === "temp" %}
+ {{ humidity() }}
+ {% endif %}
+
+ {% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}
+
+ {% if config.showFeelsLike %}
+
+ {% if config.showHumidity === "feelslike" %}
+ {{ humidity() }}
+ {% endif %}
+ {{ "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 %}
+ {% if config.showHumidity === "below" %}
+ {{ humidity() }}
+ {% endif %}
+{% else %}
+ {{ "LOADING" | translate }}
+{% endif %}
+
+
diff --git a/defaultmodules/weather/custom-weather-icons.css b/defaultmodules/weather/custom-weather-icons.css
new file mode 100644
index 0000000000..703a5eb498
--- /dev/null
+++ b/defaultmodules/weather/custom-weather-icons.css
@@ -0,0 +1,381 @@
+.icon {
+ position: relative;
+ display: inline-block;
+ width: 12em;
+ height: 10em;
+ font-size: 1em; /* control icon size here */
+}
+
+/* === Moon / Night icon (for clear-night, etc.) === */
+.icon.night {
+ position: relative;
+ display: inline-block;
+ width: 12em; /* match other icons */
+ height: 10em; /* match other icons */
+ font-size: 1em;
+}
+
+.icon.night .moon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 5.5em;
+ height: 5.5em;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: inset -1em 1em 0 0 #aaa;
+ transform: translate(-50%, -50%); /* 👈 center it exactly like sun/cloud */
+}
+
+.icon.night .moon .crater {
+ position: absolute;
+ background: #ccc;
+ border-radius: 50%;
+ width: 0.8em;
+ height: 0.8em;
+ top: 1em;
+ left: 2.2em;
+ box-shadow:
+ 1.2em 0.8em 0 0.1em #bbb,
+ 0.4em 1.8em 0 0.1em #bbb;
+}
+
+/* Fog icon from qPKoyB */
+.fog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: 1.75em 0 0;
+}
+
+.fog::before,
+.fog::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ width: 5em;
+ height: 0.5em;
+ margin-left: -2.5em;
+ color: rgb(255 255 255 / 25%);
+ background: currentcolor;
+ border-radius: 0.5em;
+ animation: fog 6s infinite linear alternate;
+}
+
+.fog::before {
+ top: 0.75em;
+}
+
+.fog::after {
+ top: 1.5em;
+ animation-delay: -6s;
+}
+
+/* Animation keyframe from the pen */
+@keyframes fog {
+ 0% {
+ transform: translateX(-0.5em);
+ }
+
+ 100% {
+ transform: translateX(0.5em);
+ }
+}
+
+.cloud {
+ position: absolute;
+ z-index: 1;
+ top: 50%;
+ left: 50%;
+ width: 3.6875em;
+ height: 3.6875em;
+ margin: -1.8438em;
+ background: currentcolor;
+ border-radius: 50%;
+ box-shadow:
+ -2.1875em 0.6875em 0 -0.6875em,
+ 2.0625em 0.9375em 0 -0.9375em,
+ 0 0 0 0.375em #fff,
+ -2.1875em 0.6875em 0 -0.3125em #fff,
+ 2.0625em 0.9375em 0 -0.5625em #fff;
+}
+
+.cloud::after {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: -0.5em;
+ display: block;
+ width: 4.5625em;
+ height: 1em;
+ background: currentcolor;
+ box-shadow: 0 0.4375em 0 -0.0625em #fff;
+}
+
+.cloud:nth-child(2) {
+ z-index: 0;
+ background: #fff;
+ box-shadow:
+ -2.1875em 0.6875em 0 -0.6875em #fff,
+ 2.0625em 0.9375em 0 -0.9375em #fff,
+ 0 0 0 0.375em #fff,
+ -2.1875em 0.6875em 0 -0.3125em #fff,
+ 2.0625em 0.9375em 0 -0.5625em #fff;
+ opacity: 0.3;
+ transform: scale(0.5) translate(6em, -3em);
+ animation: cloud 4s linear infinite;
+}
+
+.cloud:nth-child(2)::after {
+ background: #fff;
+}
+
+.sun {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 2.5em;
+ height: 2.5em;
+ margin: -1.25em;
+ background: currentcolor;
+ border-radius: 50%;
+ box-shadow: 0 0 0 0.375em #fff;
+ animation: spin 12s infinite linear;
+}
+
+.rays {
+ position: absolute;
+ top: -2em;
+ left: 50%;
+ display: block;
+ width: 0.375em;
+ height: 1.125em;
+ margin-left: -0.1875em;
+ background: #fff;
+ border-radius: 0.25em;
+ box-shadow: 0 5.375em #fff;
+}
+
+.rays::before,
+.rays::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: block;
+ width: 0.375em;
+ height: 1.125em;
+ transform: rotate(60deg);
+ transform-origin: 50% 3.25em;
+ background: #fff;
+ border-radius: 0.25em;
+ box-shadow: 0 5.375em #fff;
+}
+
+.rays::before {
+ transform: rotate(120deg);
+}
+
+.cloud + .sun {
+ margin: -2em 1em;
+}
+
+.rain,
+.lightning,
+.snow {
+ position: absolute;
+ z-index: 2;
+ top: 50%;
+ left: 50%;
+ width: 3.75em;
+ height: 3.75em;
+ margin: 0.375em 0 0 -2em;
+ background: currentcolor;
+}
+
+.rain::after {
+ content: "";
+ position: absolute;
+ z-index: 2;
+ top: 50%;
+ left: 50%;
+ width: 1.125em;
+ height: 1.125em;
+ margin: -1em 0 0 -0.25em;
+ background: #0cf;
+ border-radius: 100% 0 60% 50% / 60% 0 100% 50%;
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%),
+ -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%),
+ -1.375em -0.125em 0 rgb(255 255 255 / 20%);
+ transform: rotate(-28deg);
+ animation: rain 3s linear infinite;
+}
+
+.bolt {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -0.25em 0 0 -0.125em;
+ color: #fff;
+ opacity: 0.3;
+ animation: lightning 2s linear infinite;
+}
+
+.bolt:nth-child(2) {
+ width: 0.5em;
+ height: 0.25em;
+ margin: -1.75em 0 0 -1.875em;
+ transform: translate(2.5em, 2.25em);
+ opacity: 0.2;
+ animation: lightning 1.5s linear infinite;
+}
+
+.bolt::before,
+.bolt::after {
+ content: "";
+ position: absolute;
+ z-index: 2;
+ top: 50%;
+ left: 50%;
+ margin: -1.625em 0 0 -1.0125em;
+ border-top: 1.25em solid transparent;
+ border-right: 0.75em solid;
+ border-bottom: 0.75em solid;
+ border-left: 0.5em solid transparent;
+ transform: skewX(-10deg);
+}
+
+.bolt::after {
+ margin: -0.25em 0 0 -0.25em;
+ border-top: 0.75em solid;
+ border-right: 0.5em solid transparent;
+ border-bottom: 1.25em solid transparent;
+ border-left: 0.75em solid;
+ transform: skewX(-10deg);
+}
+
+.bolt:nth-child(2)::before {
+ margin: -0.75em 0 0 -0.5em;
+ border-top: 0.625em solid transparent;
+ border-right: 0.375em solid;
+ border-bottom: 0.375em solid;
+ border-left: 0.25em solid transparent;
+}
+
+.bolt:nth-child(2)::after {
+ margin: -0.125em 0 0 -0.125em;
+ border-top: 0.375em solid;
+ border-right: 0.25em solid transparent;
+ border-bottom: 0.625em solid transparent;
+ border-left: 0.375em solid;
+}
+
+.flake::before,
+.flake::after {
+ content: "\2744";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -1.025em 0 0 -1.0125em;
+ color: #fff;
+ line-height: 1em;
+ opacity: 0.2;
+ animation: spin 8s linear infinite reverse;
+}
+
+.flake::after {
+ margin: 0.125em 0 0 -1em;
+ font-size: 1.5em;
+ opacity: 0.4;
+ animation: spin 14s linear infinite;
+}
+
+.flake:nth-child(2)::before {
+ margin: -0.5em 0 0 0.25em;
+ font-size: 1.25em;
+ opacity: 0.2;
+ animation: spin 10s linear infinite;
+}
+
+.flake:nth-child(2)::after {
+ margin: 0.375em 0 0 0.125em;
+ font-size: 2em;
+ opacity: 0.4;
+ animation: spin 16s linear infinite reverse;
+}
+
+/* Animations */
+
+@keyframes spin {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes cloud {
+ 0% {
+ opacity: 0;
+ }
+
+ 50% {
+ opacity: 0.3;
+ }
+
+ 100% {
+ opacity: 0;
+ transform: scale(0.5) translate(-200%, -3em);
+ }
+}
+
+@keyframes rain {
+ 0% {
+ background: #0cf;
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%),
+ -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%),
+ -1.375em -0.125em 0 #0cf;
+ }
+
+ 25% {
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%),
+ -0.875em 1.125em 0 -0.125em #0cf,
+ -1.375em -0.125em 0 rgb(255 255 255 / 20%);
+ }
+
+ 50% {
+ background: rgb(255 255 255 / 30%);
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em #0cf,
+ -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%),
+ -1.375em -0.125em 0 rgb(255 255 255 / 20%);
+ }
+
+ 100% {
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%),
+ -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%),
+ -1.375em -0.125em 0 #0cf;
+ }
+}
+
+@keyframes lightning {
+ 45% {
+ color: #fff;
+ background: #fff;
+ opacity: 0.2;
+ }
+
+ 50% {
+ color: #0cf;
+ background: #0cf;
+ opacity: 1;
+ }
+
+ 55% {
+ color: #fff;
+ background: #fff;
+ opacity: 0.2;
+ }
+}
diff --git a/defaultmodules/weather/custom-weather-icons.css.wip b/defaultmodules/weather/custom-weather-icons.css.wip
new file mode 100644
index 0000000000..a32e955d0b
--- /dev/null
+++ b/defaultmodules/weather/custom-weather-icons.css.wip
@@ -0,0 +1,370 @@
+.icon {
+ position: relative;
+ display: inline-block;
+ width: 12em;
+ height: 10em;
+ font-size: 1em; /* control icon size here */
+}
+
+.cloud {
+ position: absolute;
+ z-index: 1;
+ top: 50%;
+ left: 50%;
+ width: 3.6875em;
+ height: 3.6875em;
+ margin: -1.84375em;
+ background: #000;
+ border-radius: 50%;
+ box-shadow:
+ -2.1875em 0.6875em 0 -0.6875em #000,
+ 2.0625em 0.9375em 0 -0.9375em #000,
+ 0 0 0 0.375em #fff,
+ -2.1875em 0.6875em 0 -0.3125em #fff,
+ 2.0625em 0.9375em 0 -0.5625em #fff;
+}
+.cloud:after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: -0.5em;
+ display: block;
+ width: 4.5625em;
+ height: 1em;
+ background: #000;
+ box-shadow: 0 0.4375em 0 -0.0625em #fff;
+}
+.cloud:nth-child(2) {
+ z-index: 0;
+ background: #fff;
+ box-shadow:
+ -2.1875em 0.6875em 0 -0.6875em #fff,
+ 2.0625em 0.9375em 0 -0.9375em #fff,
+ 0 0 0 0.375em #fff,
+ -2.1875em 0.6875em 0 -0.3125em #fff,
+ 2.0625em 0.9375em 0 -0.5625em #fff;
+ opacity: 0.3;
+ transform: scale(0.5) translate(6em, -3em);
+ animation: cloud 4s linear infinite;
+}
+.cloud:nth-child(2):after { background: #fff; }
+
+.sun {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 2.5em;
+ height: 2.5em;
+ margin: -1.25em;
+ background: #000;
+ border-radius: 50%;
+ box-shadow: 0 0 0 0.375em #fff;
+ animation: spin 12s infinite linear;
+}
+.rays {
+ position: absolute;
+ top: -2em;
+ left: 50%;
+ display: block;
+ width: 0.375em;
+ height: 1.125em;
+ margin-left: -0.1875em;
+ background: #fff;
+ border-radius: 0.25em;
+ box-shadow: 0 5.375em #fff;
+}
+.rays:before,
+.rays:after {
+ content: '';
+ position: absolute;
+ top: 0em;
+ left: 0em;
+ display: block;
+ width: 0.375em;
+ height: 1.125em;
+ transform: rotate(60deg);
+ transform-origin: 50% 3.25em;
+ background: #fff;
+ border-radius: 0.25em;
+ box-shadow: 0 5.375em #fff;
+}
+.rays:before {
+ transform: rotate(120deg);
+}
+.cloud + .sun {
+ margin: -2em 1em;
+}
+
+.fog {
+ position: absolute;
+ z-index: 2;
+ top: 50%;
+ left: 50%;
+ width: 3.75em;
+ height: 3.75em;
+ margin: 0.375em 0 0 -2em;
+ background: #000;
+}
+
+.rain,
+.lightning,
+.snow {
+ position: absolute;
+ z-index: 2;
+ top: 50%;
+ left: 50%;
+ width: 3.75em;
+ height: 3.75em;
+ margin: 0.375em 0 0 -2em;
+ background: #000;
+}
+
+.rain:after {
+ content: '';
+ position: absolute;
+ z-index: 2;
+ top: 50%;
+ left: 50%;
+ width: 1.125em;
+ height: 1.125em;
+ margin: -1em 0 0 -0.25em;
+ background: #0cf;
+ border-radius: 100% 0 60% 50% / 60% 0 100% 50%;
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2),
+ -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2),
+ -1.375em -0.125em 0 rgba(255,255,255,0.2);
+ transform: rotate(-28deg);
+ animation: rain 3s linear infinite;
+}
+
+.bolt {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -0.25em 0 0 -0.125em;
+ color: #fff;
+ opacity: 0.3;
+ animation: lightning 2s linear infinite;
+}
+.bolt:nth-child(2) {
+ width: 0.5em;
+ height: 0.25em;
+ margin: -1.75em 0 0 -1.875em;
+ transform: translate(2.5em, 2.25em);
+ opacity: 0.2;
+ animation: lightning 1.5s linear infinite;
+}
+.bolt:before,
+.bolt:after {
+ content: '';
+ position: absolute;
+ z-index: 2;
+ top: 50%;
+ left: 50%;
+ margin: -1.625em 0 0 -1.0125em;
+ border-top: 1.25em solid transparent;
+ border-right: 0.75em solid;
+ border-bottom: 0.75em solid;
+ border-left: 0.5em solid transparent;
+ transform: skewX(-10deg);
+}
+.bolt:after {
+ margin: -0.25em 0 0 -0.25em;
+ border-top: 0.75em solid;
+ border-right: 0.5em solid transparent;
+ border-bottom: 1.25em solid transparent;
+ border-left: 0.75em solid;
+ transform: skewX(-10deg);
+}
+.bolt:nth-child(2):before {
+ margin: -0.75em 0 0 -0.5em;
+ border-top: 0.625em solid transparent;
+ border-right: 0.375em solid;
+ border-bottom: 0.375em solid;
+ border-left: 0.25em solid transparent;
+}
+.bolt:nth-child(2):after {
+ margin: -0.125em 0 0 -0.125em;
+ border-top: 0.375em solid;
+ border-right: 0.25em solid transparent;
+ border-bottom: 0.625em solid transparent;
+ border-left: 0.375em solid;
+}
+
+.flake:before,
+.flake:after {
+ content: '\2744';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -1.025em 0 0 -1.0125em;
+ color: #fff;
+ list-height: 1em;
+ opacity: 0.2;
+ animation: spin 8s linear infinite reverse;
+}
+.flake:after {
+ margin: 0.125em 0 0 -1em;
+ font-size: 1.5em;
+ opacity: 0.4;
+ animation: spin 14s linear infinite;
+}
+.flake:nth-child(2):before {
+ margin: -0.5em 0 0 0.25em;
+ font-size: 1.25em;
+ opacity: 0.2;
+ animation: spin 10s linear infinite;
+}
+.flake:nth-child(2):after {
+ margin: 0.375em 0 0 0.125em;
+ font-size: 2em;
+ opacity: 0.4;
+ animation: spin 16s linear infinite reverse;
+}
+
+.moon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 3em;
+ height: 3em;
+ margin: -1.5em;
+ background: #000;
+ border-radius: 50%;
+ box-shadow:
+ inset -0.75em 0 rgba(255,255,255,0.2),
+ 0 0 0 0.375em #fff;
+}
+
+.stars,
+.stars:before,
+.stars:after {
+ content: '';
+ position: absolute;
+ top: 25%;
+ left: 70%;
+ width: 0.25em;
+ height: 0.25em;
+ margin: -0.125em;
+ color: rgba(255,255,255,0.5);
+ background: #000;
+ border-radius: 50%;
+ box-shadow: -4.5em 5em;
+ animation: twinkle 5s steps(2) infinite;
+}
+
+.stars:before {
+ top: 1em;
+ left: 0.25em;
+ box-shadow: -3.75em 4em;
+ opacity: 0.5;
+ animation: twinkle 4s steps(2) infinite;
+}
+
+.stars:after {
+ top: 0.5em;
+ left: -0.75em;
+ box-shadow: -3.75em 3.75em;
+ opacity: 0.25;
+ animation: twinkle 3s steps(2) infinite;
+}
+
+/*.fog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: 1.75em 0 0 0;
+}*/
+
+.fog::before,
+.fog::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ width: 5em;
+ height: 0.5em;
+ margin-left: -2.5em;
+ color: rgba(255,255,255,0.25);
+ background: currentColor;
+ border-radius: 0.5em;
+ animation: fog 6s infinite linear alternate;
+}
+
+.fog::before { top: 0.75em; }
+
+.fog::after {
+ top: 1.5em;
+ animation-delay: -6s;
+}
+
+/* Animations */
+
+@keyframes spin {
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes cloud {
+ 0% { opacity: 0; }
+ 50% { opacity: 0.3; }
+ 100% {
+ opacity: 0;
+ transform: scale(0.5) translate(-200%, -3em);
+ }
+}
+
+@keyframes rain {
+ 0% {
+ background: #0cf;
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2),
+ -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2),
+ -1.375em -0.125em 0 #0cf;
+ }
+ 25% {
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2),
+ -0.875em 1.125em 0 -0.125em #0cf,
+ -1.375em -0.125em 0 rgba(255,255,255,0.2);
+ }
+ 50% {
+ background: rgba(255,255,255,0.3);
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em #0cf,
+ -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2),
+ -1.375em -0.125em 0 rgba(255,255,255,0.2);
+ }
+ 100% {
+ box-shadow:
+ 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2),
+ -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2),
+ -1.375em -0.125em 0 #0cf;
+ }
+}
+
+@keyframes lightning {
+ 45% {
+ color: #fff;
+ background: #fff;
+ opacity: 0.2;
+ }
+ 50% {
+ color: #0cf;
+ background: #0cf;
+ opacity: 1;
+ }
+ 55% {
+ color: #fff;
+ background: #fff;
+ opacity: 0.2;
+ }
+}
+
+@keyframes twinkle {
+ 100% { color: rgba(255,255,255,0.2); }
+}
+
+@keyframes fog {
+ 0% { transform: translateX(-0.5em); }
+ 100% { transform: translateX(0.5em); }
+}
diff --git a/defaultmodules/weather/forecast.njk.wip b/defaultmodules/weather/forecast.njk.wip
new file mode 100644
index 0000000000..dffaa9b0ce
--- /dev/null
+++ b/defaultmodules/weather/forecast.njk.wip
@@ -0,0 +1,46 @@
+{% 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 %}
+ {{ "TODAY" | translate }}
+ {% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %}
+ {{ "TOMORROW" | translate }}
+ {% else %}
+ {{ f.date.format("ddd") }}
+ {% endif %}
+
+ {{ f.iconHTML | safe }}
+
+ {{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}
+ {{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}
+ {% if config.showPrecipitationAmount %}
+ {{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}
+ {% endif %}
+ {% if config.showPrecipitationProbability %}
+ {{ f.precipitationProbability | unit('precip', '%') }}
+ {% endif %}
+ {% if config.showUVIndex %}
+
+ {{ f.uv_index }}
+
+
+ {% endif %}
+
+ {% set currentStep = currentStep + 1 %}
+ {% endfor %}
+
+{% else %}
+ {{ "LOADING" | translate }}
+{% endif %}
+
+
diff --git a/defaultmodules/weather/weather.js.wip b/defaultmodules/weather/weather.js.wip
new file mode 100644
index 0000000000..f028811962
--- /dev/null
+++ b/defaultmodules/weather/weather.js.wip
@@ -0,0 +1,421 @@
+/* global WeatherProvider, WeatherUtils, formatTime */
+
+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: "none", // possible options for "current" weather are "none", "wind", "temp", "feelslike" or "below", for "hourly" weather "none" or "true"
+ hideZeroes: false, // hide zeroes (and empty columns) in hourly, currently only for precipitation
+ showIndoorHumidity: false,
+ showIndoorTemperature: false,
+ allowOverrideNotification: false,
+ showPeriod: true,
+ showPeriodUpper: false,
+ showPrecipitationAmount: false,
+ showPrecipitationProbability: false,
+ showUVIndex: false,
+ showSun: true,
+ showWindDirection: true,
+ showWindDirectionAsArrow: false,
+ degreeLabel: false,
+ decimalSymbol: ".",
+ maxNumberOfDays: 5,
+ maxEntries: 5,
+ ignoreToday: false,
+ fade: true,
+ fadePoint: 0.25, // Start on 1/4th of the list.
+ initialLoadDelay: 0, // 0 seconds delay
+ appendLocationNameToHeader: true,
+ calendarClass: "calendar",
+ tableClass: "small",
+ onlyTemp: false,
+ colored: false,
+ absoluteDates: false,
+ 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 () {
+ return ["font-awesome.css", "weather-icons.css", "weather.css", "custom-weather-icons.css"];
+ },
+
+ // Return the scripts that are necessary for the weather module.
+ getScripts () {
+ return ["moment.js", "weatherutils.js", "weatherobject.js", this.file("providers/overrideWrapper.js"), "weatherprovider.js", "suncalc.js", this.file(`providers/${this.config.weatherProvider.toLowerCase()}.js`)];
+ },
+
+ // Override getHeader method.
+ getHeader () {
+ 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 () {
+ 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";
+ }
+ if (typeof this.config.showHumidity === "boolean") {
+ Log.warn("[weather] Deprecation warning: Please consider updating showHumidity to the new style (config string).");
+ this.config.showHumidity = this.config.showHumidity ? "wind" : "none";
+ }
+
+ // 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 (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);
+ } else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) {
+ this.weatherProvider.notificationReceived(payload);
+ }
+ },
+
+ // Select the template depending on the display type.
+ getTemplate () {
+ 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 () {
+ const currentData = this.weatherProvider.currentWeather();
+ const forecastData = this.weatherProvider.weatherForecast();
+
+ const mapIcon = (weatherType) => {
+ console.log("Weather type received:", weatherType);
+ switch (weatherType) {
+ case "day-sunny":
+ case "day-windy":
+ case "day-light-wind":
+ case "solar-eclipse":
+ case "hot":
+ return ``;
+ case "lunar-eclipse":
+ case "stars":
+ return ``;
+ case "day-rain":
+ case "day-rain-mix":
+ case "day-rain-wind":
+ case "day-showers":
+ case "day-sleet":
+ case "day-sprinkle":
+ case "day-sunny-overcast":
+ case "night-alt-rain":
+ case "night-alt-rain-mix":
+ case "night-alt-rain-wind":
+ case "night-alt-showers":
+ case "night-alt-sleet":
+ case "night-alt-sprinkle":
+ case "night-rain":
+ case "night-rain-mix":
+ case "night-rain-wind":
+ case "night-showers":
+ case "night-sleet":
+ case "night-sprinkle":
+ case "rain":
+ case "rain-mix":
+ case "rain-wind":
+ case "showers":
+ case "sleet":
+ case "sprinkle":
+ return ``;
+ case "day-hail":
+ case "day-snow":
+ case "day-snow-thunderstorm":
+ case "day-snow-wind":
+ case "night-alt-hail":
+ case "night-alt-snow":
+ case "night-alt-snow-thunderstorm":
+ case "night-alt-snow-wind":
+ case "night-hail":
+ case "night-snow":
+ case "night-snow-thunderstorm":
+ case "night-snow-wind":
+ case "hail":
+ case "snow":
+ case "snow-wind":
+ return ``;
+ case "day-lightning":
+ case "day-sleet-storm":
+ case "day-storm-showers":
+ case "day-thunderstorm":
+ case "night-alt-lightning":
+ case "night-alt-sleet-storm":
+ case "night-alt-storm-showers":
+ case "night-alt-thunderstorm":
+ case "night-lightning":
+ case "night-sleet-storm":
+ case "night-storm-showers":
+ case "night-thunderstorm":
+ case "storm-showers":
+ case "thunderstorm":
+ case "lightning":
+ return ``;
+ case "day-cloudy":
+ case "day-cloudy-gusts":
+ case "day-cloud-windy":
+ case "day-cloudy-high":
+ case "night-alt-cloudy":
+ case "night-alt-cloudy-gusts":
+ case "night-alt-cloudy-windy":
+ case "night-alt-cloudy-high":
+ case "night-alt-partly-cloudy":
+ case "night-cloudy":
+ case "night-cloudy-gusts":
+ case "night-cloudy-windy":
+ case "night-partly-cloudy":
+ case "night-cloudy-high":
+ case "cloud":
+ case "cloudy":
+ case "cloudy-gusts":
+ case "cloudy-windy":
+ return ``;
+ case "night-clear":
+ case "day-fog":
+ case "day-haze":
+ case "night-fog":
+ case "fog":
+ case "smog":
+ case "smoke ":
+ return ``;
+ default:
+ return ``;
+ }
+ };
+
+ if (forecastData) {
+ forecastData.forEach(f => {
+ console.log("Current weather object:", currentData);
+ f.iconHTML = mapIcon(f.weatherType);
+ });
+ }
+
+ // Add iconHTML to current weather
+ if (currentData) {
+ currentData.iconHTML = mapIcon(currentData.weatherType);
+ }
+
+ // 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 () {
+ Log.log("New weather information available.");
+ // this value was changed from 0 to 300 to stabilize weather tests:
+ this.updateDom(300);
+ this.scheduleUpdate();
+
+ if (this.weatherProvider.currentWeather()) {
+ this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType?.replace("-", "_") });
+ }
+
+ const notificationPayload = {
+ currentWeather: this.config.units === "imperial"
+ ? WeatherUtils.convertWeatherObjectToImperial(this.weatherProvider?.currentWeatherObject?.simpleClone()) ?? null
+ : this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null,
+ forecastArray: this.config.units === "imperial"
+ ? this.weatherProvider?.weatherForecastArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
+ : this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [],
+ hourlyArray: this.config.units === "imperial"
+ ? this.weatherProvider?.weatherHourlyArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
+ : this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [],
+ locationName: this.weatherProvider?.fetchedLocationName,
+ providerName: this.weatherProvider.providerName
+ };
+
+ this.sendNotification("WEATHER_UPDATED", notificationPayload);
+ },
+
+ scheduleUpdate (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 (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)) {
+ 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)
+ );
+ }
+});