v0.8.3
This commit is contained in:
@@ -1,3 +1,38 @@
|
||||
# GREE Controller v0.8.3 - build and validation report
|
||||
|
||||
## 0.8.3 Temporary Quick Thermostat sessions
|
||||
|
||||
Source baseline: v0.8.2.
|
||||
|
||||
### Changes
|
||||
|
||||
- Added a separate persisted Temporary Quick Thermostat session, independent of the existing `local_thermostat_resume_at` 15-minute local-OFF hand-back mechanism.
|
||||
- Added finish rules for duration, absolute date/time, temperature reached, continuously held temperature and next schedule boundary.
|
||||
- Temperature rules support `within`, `at_or_below` and `at_or_above`, configurable tolerance, continuous hold time and an optional maximum safety duration.
|
||||
- Added active-session countdown/status in the dashboard Quick Thermostat card and a configuration dialog for starting or updating a session.
|
||||
- Moved the zone automation enabled switch outside the Quick Thermostat controls to separate automation availability from local thermostat operation.
|
||||
- A temporary session can temporarily enable a disabled zone and restores the pre-session enabled state when it ends.
|
||||
- Manual Quick Thermostat power changes cancel the temporary session, while target changes update temperature-based session targets safely.
|
||||
- Bumped controller and Home Assistant integration version to 0.8.3 and rotated the PWA service-worker cache key.
|
||||
|
||||
### Validation executed in this environment
|
||||
|
||||
- `node --check web/app.js`
|
||||
- `node --check web/theme-init.js`
|
||||
- `node --check web/sw.js`
|
||||
- parsed all JSON files
|
||||
- `python3 -m compileall -q home-assistant scripts make_zip.py`
|
||||
- `bash -n scripts/*.sh`
|
||||
- checked version consistency across Cargo.toml, Cargo.lock, README and Home Assistant manifest
|
||||
- regenerated and verified `FILE_MANIFEST.sha256`
|
||||
- tested the final ZIP with `unzip -t`
|
||||
|
||||
### Rust toolchain note
|
||||
|
||||
This execution environment does not contain `cargo`, `rustc` or `rustfmt`, so Rust tests and full compilation cannot be executed here. The installer/updater still runs `cargo test --all-targets` before replacing the running service.
|
||||
|
||||
---
|
||||
|
||||
# GREE Controller v0.8.2 - build and validation report
|
||||
|
||||
## 0.8.2 UI/i18n + Home Assistant sensor freshness
|
||||
|
||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+16
-16
@@ -1,12 +1,12 @@
|
||||
b1347a7cbb561b78c52a6045b162bf8af46b841abdca3b1e25a01225eb34c189 ./.env.example
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
f3b1bbc242d26f4079394b893b2ab3c106920a7d6822fd92f7788cd6a2e1ad16 ./BUILD_REPORT.md
|
||||
029e59328b60468a5da40bd17aaae6a4a0082eab85b2033ad375d0c31baca967 ./Cargo.lock
|
||||
a815c1e8134a4d3f9afee6ab53911a5be6b0ce746435722787e150ab219c45e0 ./Cargo.toml
|
||||
15f85a3e81f0c1078faec87f72e54bfa1330bbe230b69486316aafef5c556a5d ./BUILD_REPORT.md
|
||||
42cf2fd754c44be8beb22b6b63df94b1f633bd6e54256790778f9f732c95652f ./Cargo.lock
|
||||
ed2db5ae8ae02b43e603264b9dc771e1c7f6e0e3f522337d9dad8c462f80590c ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
05b5ebed3bb136b648e5a1d97ca6b268cef898d4bf8ab8d92abfcf66bbf5242e ./README.md
|
||||
dd40e740be80cafc6905d82ad061b0a3bf5ed9b26687b31a07d69ec8b3c0c066 ./README.md
|
||||
91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs
|
||||
4fe34356d313ab624b4bb0de09c271999262cbd363cc1ede15af89a93a82b3ea ./docs/API.md
|
||||
2562170218783698d32be10cafa0758a27bfb4217b32bdecad4c9ac7d9b0e7eb ./docs/API.md
|
||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||
@@ -24,7 +24,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||
d54d23f1f1dc633f26c493b138d061deb97a063b3d79dd91f85aaddf126bc427 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
17f0223d48c8db0a1e7cd58042d8fa3d118f2bee432bccfaac27f62c0597a3e5 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
||||
1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||
@@ -33,8 +33,8 @@ d54d23f1f1dc633f26c493b138d061deb97a063b3d79dd91f85aaddf126bc427 ./home-assista
|
||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
9f5a96bc8837aa5ea303b8b0dc10fcb629e084f64dfbb8f3b427705897e5ecdb ./lang/en.json
|
||||
e0099f407b5ba49a8fe21bfc0fc8f7c0a092806c42bcc28061e6306f23b0ea8d ./lang/pl.json
|
||||
e02abc97898883ebdcc84bf3908c198d9e945ec350f3c749b4187327a8d35b7d ./lang/en.json
|
||||
89073a413f651fdc1ec2492b9cc860147259faccf08444bf9b6878e4f33bd260 ./lang/pl.json
|
||||
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
@@ -47,15 +47,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
0bc3da5b1148aa54d2b234592d740bc262738d386f27b2f316465651ebedf891 ./src/api.rs
|
||||
3d84f4606da4a1b8505b2e9b619320520d27058299b4561a52b564de06b0ad18 ./src/api.rs
|
||||
9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs
|
||||
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
|
||||
83f1a0bd19bc00b9f13e5207e11b6e21ca7d2b385136330f273ca502b292cecd ./src/engine.rs
|
||||
22e42c9f9727deccc41a901d2aba148aa06ef6b7d262956ba9ed54f50590a2ce ./src/engine.rs
|
||||
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
||||
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
|
||||
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
||||
c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs
|
||||
f74f5ac1940cd1c9cd8ee610c56f68b9fe17646c97736e951cd1c42408bd5b89 ./src/models.rs
|
||||
054dda2e93faf27028a3117bc4440da9f54b67819de2e0de744e487e496b5899 ./src/models.rs
|
||||
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs
|
||||
@@ -63,10 +63,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol
|
||||
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
|
||||
2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
cc9f2b846fb66fcb540851bdbe4d751fd297f04dac3d6a076fe1597281059992 ./web/app.js
|
||||
3d0eb4ffc1877165b4a95bf4fd42676643878358ef0878324228b44f0a6954ef ./web/app.js
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
a4127eb010479bf50a940edbddd068ab3afb6e5386e4ddc383950112fe499b6a ./web/index.html
|
||||
97b0df94611803c4fe70559b32c961600526218b079a372456b38144c7a61ce4 ./web/index.html
|
||||
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||
1404cd00f52605c347b86ef84d95c74c47f48f5fa82e763b0ae99c62e9800850 ./web/styles.css
|
||||
06d7293a1e9b2022503e58d06a43b27dadb8cc10acfe3dbb3bec2325b33b2b04 ./web/sw.js
|
||||
6aa06f52e4c6391a43016a6a585b7422e3de319744db40a9b233beb27e57412d ./web/styles.css
|
||||
ca7c496ad22a8fa7625f99b65ffec8881d4edae613bea58746f18113f2fc62b5 ./web/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
|
||||
|
||||
Current version: **0.8.2**.
|
||||
Current version: **0.8.3**.
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -47,7 +47,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
|
||||
On Debian, Ubuntu or an LXC container:
|
||||
|
||||
```bash
|
||||
unzip gree-controller-v0.8.2.zip
|
||||
unzip gree-controller-v0.8.3.zip
|
||||
cd gree-controller
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/dev.sh
|
||||
@@ -480,6 +480,14 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C
|
||||
|
||||
|
||||
|
||||
## 0.8.3 Temporary Quick Thermostat sessions
|
||||
|
||||
- Adds a separate persisted timed Quick Thermostat session without reusing the existing 15-minute local-OFF hand-back timer.
|
||||
- Supports ending after a duration, at a date/time, after reaching a temperature, after continuously holding a temperature condition, or at the next schedule boundary.
|
||||
- Temperature conditions support within/at-or-below/at-or-above operators, tolerance and an optional safety timeout.
|
||||
- Active sessions show their countdown/status directly on the Quick Thermostat card and restore the previous automation-enabled state when finished.
|
||||
- Moves the zone automation enabled switch out of Quick Thermostat controls so automation availability and temporary thermostat ownership are visually separate.
|
||||
|
||||
## 0.8.2 UI/i18n and Home Assistant sensor freshness
|
||||
|
||||
- fixes ownership translations that were accidentally stored outside the language-pack `translations` object,
|
||||
|
||||
+15
@@ -133,12 +133,27 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl
|
||||
# Return local thermostat power ownership to the group/schedule.
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"clear_local_thermostat_override":true}'
|
||||
|
||||
# Run Quick Thermostat for 90 minutes, then hand control back to automation.
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"temporary_quick_thermostat":{"finish_kind":"duration","duration_minutes":90,"target_temperature":23.0}}'
|
||||
|
||||
# Hold 23 C continuously for one hour. Leaving the +/-0.3 C band resets the hold timer.
|
||||
# The optional safety limit hands control back after four hours even if the condition never settles.
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"temporary_quick_thermostat":{"finish_kind":"temperature_stable","target_temperature":23.0,"temperature_operator":"within","tolerance_c":0.3,"hold_minutes":60,"max_duration_minutes":240}}'
|
||||
|
||||
# Cancel only the temporary Quick Thermostat session and return to the previous automation state.
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"clear_temporary_quick_thermostat":true}'
|
||||
```
|
||||
|
||||
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow global mode** in the UI and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones.
|
||||
|
||||
`power:true/false` on `/api/zones/{id}/control` is a local quick-thermostat power override, not a physical/manual command. `true` lets that zone run through the full thermostat even when its climate group is off and cancels any pending local hand-back timer. Every accepted `false` action turns only that zone off immediately and creates a fresh backend-owned `local_thermostat_resume_at` 15 minutes in the future. When that deadline is reached, the controller clears local quick ownership plus its temporary quick preset/setpoint and recalculates the current group/schedule/house-mode state instead of restoring an old physical ON/OFF snapshot. `clear_local_thermostat_override:true` performs that hand-back immediately. Direct/pilot-style control remains `/api/devices/{id}/command`.
|
||||
|
||||
`temporary_quick_thermostat` is a separate persisted session and does **not** reuse `local_thermostat_resume_at`. Supported `finish_kind` values are `duration`, `until`, `temperature_reached`, `temperature_stable`, and `schedule_boundary`. Temperature rules support `within`, `at_or_below`, and `at_or_above`, a tolerance, an optional safety deadline, and for `temperature_stable` a continuous hold duration. If the zone automation was disabled before the session, the session temporarily enables thermostat control and restores the disabled state when it finishes.
|
||||
|
||||
### Physical/manual device takeover
|
||||
|
||||
The poller compares climate-relevant unit state with the last controller-known state. An external change of power, HVAC mode, target temperature or fan speed (for example from the IR remote) sets `device_manual_override=true` on the assigned zone. Controller-originated thermostat/group commands are correlated with a short-lived expected-state record when GREE status has not settled yet, so their delayed status transition is not mistaken for an external takeover. The zone continues sensor/history updates but thermostat modulation, schedules, groups and direct-device automations stop issuing corrective climate commands. The controller stores the pre-takeover climate state; if the user later returns the unit to that operational state, the takeover is cleared automatically and the stale **Resume automation** prompt disappears. When the pre-takeover state was OFF, switching the unit OFF again is sufficient even if the remote kept a different dormant target/mode internally. If that OFF state belongs to an active local quick-thermostat hand-back, its countdown is suspended while direct/manual takeover is active and is re-armed for a fresh 15 minutes when the unit returns to OFF. Otherwise the manual-device override expires at the next schedule transition when one exists; without a future transition it stays active until explicitly resumed. The known GREE standby normalization from Low fan back to Auto is ignored so it does not create a false takeover.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
+40
-1
@@ -623,6 +623,45 @@
|
||||
"zones.ownerResume": "until",
|
||||
"zones.lockoutUntil": "lockout until",
|
||||
"settings.haSensorStaleAfterMinutes": "Maximum HA sensor reading age (min)",
|
||||
"settings.haSensorStaleAfterHint": "If the HA sensor is not updated within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when available."
|
||||
"settings.haSensorStaleAfterHint": "If the HA sensor is not updated within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when available.",
|
||||
"zones.quickThermostat": "Quick thermostat",
|
||||
"zones.temporaryTitle": "Temporary mode",
|
||||
"zones.temporaryActive": "Temporary Quick Thermostat active",
|
||||
"zones.temporaryStop": "Finish and return to automation",
|
||||
"zones.temporaryTarget": "Quick thermostat temperature",
|
||||
"zones.temporaryFinishRule": "When to return to automation",
|
||||
"zones.temporaryDuration": "After a duration",
|
||||
"zones.temporaryUntil": "At a specific date and time",
|
||||
"zones.temporaryReached": "When a temperature condition is reached",
|
||||
"zones.temporaryStable": "After a temperature condition stays stable",
|
||||
"zones.temporaryScheduleBoundary": "At the next schedule transition",
|
||||
"zones.temporaryDurationMinutes": "Run time (min)",
|
||||
"zones.temporaryUntilDate": "Finish at",
|
||||
"zones.temporaryCondition": "Condition",
|
||||
"zones.temporaryWithin": "within target ± tolerance",
|
||||
"zones.temporaryAtOrBelow": "temperature ≤ target",
|
||||
"zones.temporaryAtOrAbove": "temperature ≥ target",
|
||||
"zones.temporaryTolerance": "Tolerance °C",
|
||||
"zones.temporaryHoldMinutes": "Condition must hold for (min)",
|
||||
"zones.temporarySafetyLimit": "Safety limit (min, optional)",
|
||||
"zones.temporaryStableHint": "If temperature leaves the condition, the hold countdown resets and starts again after the condition is met again.",
|
||||
"zones.temporaryScheduleHint": "Quick Thermostat will finish at the next change of the active schedule entry for this zone.",
|
||||
"zones.temporaryStart": "Start temporarily",
|
||||
"zones.temporaryUpdate": "Update rules",
|
||||
"zones.temporaryShort": "Timed",
|
||||
"zones.temporaryStartHint": "Run Quick Thermostat temporarily or until a condition is met.",
|
||||
"zones.temporaryOverride": "Temporary · {time}",
|
||||
"zones.temporaryWithinShort": "{target}°C ±{tolerance}°C",
|
||||
"zones.temporaryAtOrBelowShort": "≤ {target}°C · tol. {tolerance}°C",
|
||||
"zones.temporaryAtOrAboveShort": "≥ {target}°C · tol. {tolerance}°C",
|
||||
"zones.temporaryHoldingStatus": "Condition met: {condition}. Stability countdown is running.",
|
||||
"zones.temporaryWaitingStable": "Waiting for continuous condition: {condition}.",
|
||||
"zones.temporaryWaitingReached": "Waiting for condition: {condition}.",
|
||||
"zones.temporaryUntilScheduleStatus": "Until the next schedule transition",
|
||||
"zones.temporaryTimeStatus": "Until automatic return to automation",
|
||||
"zones.temporaryStopped": "Temporary mode finished — automation resumed control",
|
||||
"zones.temporaryStarted": "Temporary Quick Thermostat started",
|
||||
"zones.temporaryInvalidUntil": "Enter a valid finish date and time",
|
||||
"zones.automationToggleHint": "Enables or disables thermostat automation for this zone. This is not temporary control."
|
||||
}
|
||||
}
|
||||
|
||||
+40
-1
@@ -623,6 +623,45 @@
|
||||
"zones.ownerResume": "do",
|
||||
"zones.lockoutUntil": "blokada do",
|
||||
"settings.haSensorStaleAfterMinutes": "Maksymalny wiek odczytu sensora HA (min)",
|
||||
"settings.haSensorStaleAfterHint": "Jeśli sensor HA nie zaktualizuje się przez ten czas, odczyt zostanie uznany za nieaktualny. Termostat użyje czujnika GREE jako awaryjnego źródła, jeśli jest dostępny."
|
||||
"settings.haSensorStaleAfterHint": "Jeśli sensor HA nie zaktualizuje się przez ten czas, odczyt zostanie uznany za nieaktualny. Termostat użyje czujnika GREE jako awaryjnego źródła, jeśli jest dostępny.",
|
||||
"zones.quickThermostat": "Szybki termostat",
|
||||
"zones.temporaryTitle": "Tryb czasowy",
|
||||
"zones.temporaryActive": "Aktywny szybki termostat",
|
||||
"zones.temporaryStop": "Zakończ i wróć do automatyki",
|
||||
"zones.temporaryTarget": "Temperatura szybkiego termostatu",
|
||||
"zones.temporaryFinishRule": "Kiedy wrócić do automatyki",
|
||||
"zones.temporaryDuration": "Po określonym czasie",
|
||||
"zones.temporaryUntil": "O konkretnej dacie i godzinie",
|
||||
"zones.temporaryReached": "Po osiągnięciu warunku temperatury",
|
||||
"zones.temporaryStable": "Po utrzymaniu warunku temperatury",
|
||||
"zones.temporaryScheduleBoundary": "Przy następnym przejściu harmonogramu",
|
||||
"zones.temporaryDurationMinutes": "Czas działania (min)",
|
||||
"zones.temporaryUntilDate": "Wyłącz o",
|
||||
"zones.temporaryCondition": "Warunek",
|
||||
"zones.temporaryWithin": "w zakresie celu ± tolerancja",
|
||||
"zones.temporaryAtOrBelow": "temperatura ≤ cel",
|
||||
"zones.temporaryAtOrAbove": "temperatura ≥ cel",
|
||||
"zones.temporaryTolerance": "Tolerancja °C",
|
||||
"zones.temporaryHoldMinutes": "Warunek musi trwać (min)",
|
||||
"zones.temporarySafetyLimit": "Limit bezpieczeństwa (min, opcjonalnie)",
|
||||
"zones.temporaryStableHint": "Jeśli temperatura wyjdzie poza warunek, licznik utrzymania zeruje się i zaczyna od nowa po ponownym spełnieniu warunku.",
|
||||
"zones.temporaryScheduleHint": "Szybki termostat zakończy się przy najbliższej zmianie aktywnego wpisu harmonogramu tej strefy.",
|
||||
"zones.temporaryStart": "Włącz czasowo",
|
||||
"zones.temporaryUpdate": "Zmień zasady",
|
||||
"zones.temporaryShort": "Czasowo",
|
||||
"zones.temporaryStartHint": "Włącz szybki termostat czasowo lub do spełnienia warunku.",
|
||||
"zones.temporaryOverride": "Czasowo · {time}",
|
||||
"zones.temporaryWithinShort": "{target}°C ±{tolerance}°C",
|
||||
"zones.temporaryAtOrBelowShort": "≤ {target}°C · tol. {tolerance}°C",
|
||||
"zones.temporaryAtOrAboveShort": "≥ {target}°C · tol. {tolerance}°C",
|
||||
"zones.temporaryHoldingStatus": "Warunek spełniony: {condition}. Trwa odliczanie stabilizacji.",
|
||||
"zones.temporaryWaitingStable": "Oczekiwanie na ciągłe spełnienie: {condition}.",
|
||||
"zones.temporaryWaitingReached": "Oczekiwanie na spełnienie: {condition}.",
|
||||
"zones.temporaryUntilScheduleStatus": "Do następnej zmiany harmonogramu",
|
||||
"zones.temporaryTimeStatus": "Do automatycznego powrotu do automatyki",
|
||||
"zones.temporaryStopped": "Tryb czasowy zakończony — automatyka przejęła sterowanie",
|
||||
"zones.temporaryStarted": "Tryb czasowy szybkiego termostatu uruchomiony",
|
||||
"zones.temporaryInvalidUntil": "Podaj prawidłową datę i godzinę zakończenia",
|
||||
"zones.automationToggleHint": "Włącza lub wyłącza automatykę termostatu tej strefy. Nie jest to chwilowe sterowanie."
|
||||
}
|
||||
}
|
||||
|
||||
+111
-7
@@ -23,7 +23,7 @@ use crate::{
|
||||
home_assistant,
|
||||
influxdb,
|
||||
notifications,
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -540,7 +540,7 @@ impl ZoneInput {
|
||||
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
||||
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds,
|
||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None,
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None,
|
||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(),
|
||||
last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
|
||||
@@ -611,6 +611,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
zone.manual_override_until = existing.manual_override_until;
|
||||
zone.local_thermostat_power = existing.local_thermostat_power;
|
||||
zone.local_thermostat_resume_at = existing.local_thermostat_resume_at;
|
||||
zone.temporary_quick_thermostat = existing.temporary_quick_thermostat;
|
||||
zone.device_manual_override = existing.device_manual_override;
|
||||
zone.device_manual_override_since = existing.device_manual_override_since;
|
||||
zone.device_manual_override_until = existing.device_manual_override_until;
|
||||
@@ -666,19 +667,102 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
|
||||
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
|
||||
let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false);
|
||||
// Any thermostat action takes ownership back from a physical/pilot takeover. Local power
|
||||
// is a thermostat state of its own and must never be recorded as device manual control.
|
||||
let resume_device_automation = resume_device_takeover
|
||||
|| patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|
||||
|| patch.preset.is_some() || patch.enabled.is_some();
|
||||
if resume_device_automation || resume_local_thermostat {
|
||||
|| patch.preset.is_some() || patch.enabled.is_some() || patch.temporary_quick_thermostat.is_some();
|
||||
if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() {
|
||||
zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() };
|
||||
}
|
||||
|
||||
if resume_local_thermostat {
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
}
|
||||
if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() {
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
}
|
||||
if let Some(request) = patch.temporary_quick_thermostat.as_ref() {
|
||||
let now = Utc::now();
|
||||
let finish_kind = request.finish_kind.as_str();
|
||||
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
|
||||
}
|
||||
|
||||
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
|
||||
if !(8.0..=30.0).contains(&target) {
|
||||
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
|
||||
}
|
||||
let target = (target * 2.0).round() / 2.0;
|
||||
let tolerance = request.tolerance_c.unwrap_or(0.3);
|
||||
if !(0.1..=3.0).contains(&tolerance) {
|
||||
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
|
||||
}
|
||||
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
|
||||
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
|
||||
}
|
||||
|
||||
let expires_at = match finish_kind {
|
||||
"duration" => {
|
||||
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
|
||||
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
|
||||
Some(now.clone() + ChronoDuration::minutes(minutes as i64))
|
||||
}
|
||||
"until" => {
|
||||
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
|
||||
if until <= now { return Err(AppError::BadRequest("temporary thermostat end time must be in the future".into())); }
|
||||
if until > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days away".into())); }
|
||||
Some(until)
|
||||
}
|
||||
"schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
|
||||
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable");
|
||||
let hold_seconds = if finish_kind == "temperature_stable" {
|
||||
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
|
||||
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
|
||||
minutes.saturating_mul(60)
|
||||
} else { 0 };
|
||||
let safety_expires_at = if is_temperature_condition {
|
||||
request.max_duration_minutes.map(|minutes| {
|
||||
if !(1..=14_400).contains(&minutes) {
|
||||
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
|
||||
}
|
||||
Ok(now.clone() + ChronoDuration::minutes(minutes as i64))
|
||||
}).transpose()?
|
||||
} else { None };
|
||||
|
||||
// Replacing/editing an active temporary session must keep the state that existed
|
||||
// before the very first temporary takeover. Otherwise editing a session that was
|
||||
// started from a disabled zone would incorrectly restore automation as enabled.
|
||||
let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref()
|
||||
.and_then(|session| session.restore_zone_enabled)
|
||||
.unwrap_or(zone.enabled);
|
||||
engine::set_local_thermostat_power(&mut zone, true, now.clone());
|
||||
zone.enabled = true;
|
||||
zone.setpoint = target;
|
||||
zone.manual_setpoint = Some(target);
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.manual_override_until = None;
|
||||
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
|
||||
finish_kind: finish_kind.into(),
|
||||
started_at: now,
|
||||
restore_zone_enabled: Some(restore_zone_enabled),
|
||||
expires_at,
|
||||
temperature_target: is_temperature_condition.then_some(target),
|
||||
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
|
||||
tolerance_c: tolerance,
|
||||
hold_seconds,
|
||||
condition_started_at: None,
|
||||
safety_expires_at,
|
||||
});
|
||||
}
|
||||
if let Some(power) = patch.power {
|
||||
zone.temporary_quick_thermostat = None;
|
||||
engine::set_local_thermostat_power(&mut zone, power, Utc::now());
|
||||
if power { zone.enabled = true; }
|
||||
// A manually started local thermostat keeps an already selected target/profile until
|
||||
@@ -694,6 +778,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
zone.setpoint = value;
|
||||
zone.manual_setpoint = Some(value);
|
||||
zone.effective_setpoint = Some(value);
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
|
||||
session.temperature_target = Some(value);
|
||||
session.condition_started_at = None;
|
||||
}
|
||||
}
|
||||
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
|
||||
None
|
||||
} else {
|
||||
@@ -735,8 +825,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
if let Some(value) = patch.enabled {
|
||||
zone.enabled = value;
|
||||
if !value { engine::reset_local_thermostat_override(&mut zone); }
|
||||
if !value {
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
zone.enabled = false;
|
||||
} else {
|
||||
zone.enabled = true;
|
||||
}
|
||||
}
|
||||
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
|
||||
let runtime = state.settings.read().await.clone();
|
||||
@@ -745,6 +839,15 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
&& state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group);
|
||||
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
if (patch.preset.is_some() || patch.clear_override.unwrap_or(false)) && zone.temporary_quick_thermostat.is_some() {
|
||||
let current_target = zone.effective_setpoint;
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
|
||||
session.temperature_target = current_target;
|
||||
session.condition_started_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -773,6 +876,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
"override_until": zone.manual_override_until, "enabled": zone.enabled,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"temporary_quick_thermostat": zone.temporary_quick_thermostat,
|
||||
"device_manual_override_cleared": device_override_cleared
|
||||
}));
|
||||
Ok(zone)
|
||||
@@ -1074,7 +1178,7 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
||||
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut cleared = 0;
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() { continue; }
|
||||
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
|
||||
+211
-8
@@ -7,7 +7,7 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
|
||||
state::{AppState, PendingControllerCommand},
|
||||
};
|
||||
|
||||
@@ -48,10 +48,10 @@ pub fn start(state: AppState) {
|
||||
}
|
||||
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
|
||||
let normal_delay = Duration::from_secs(seconds);
|
||||
let resume_delay = match next_local_thermostat_resume_delay(&control_state) {
|
||||
let resume_delay = match next_zone_control_deadline_delay(&control_state) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, "cannot calculate local thermostat resume deadline");
|
||||
tracing::warn!(error=?err, "cannot calculate thermostat control deadline");
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -622,19 +622,123 @@ fn local_thermostat_handback_is_active(zone: &Zone) -> bool {
|
||||
}
|
||||
|
||||
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
|
||||
let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
|
||||
let changed = zone.local_thermostat_power.is_some()
|
||||
|| zone.local_thermostat_resume_at.is_some()
|
||||
|| zone.temporary_quick_thermostat.is_some()
|
||||
|| zone.manual_preset.is_some()
|
||||
|| zone.manual_setpoint.is_some()
|
||||
|| zone.manual_override_until.is_some();
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.temporary_quick_thermostat = None;
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
if let Some(enabled) = restore_zone_enabled {
|
||||
zone.enabled = enabled;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
|
||||
match (session.expires_at.clone(), session.safety_expires_at.clone()) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
|
||||
let hard = temporary_quick_thermostat_hard_deadline(session);
|
||||
let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 {
|
||||
session.condition_started_at.clone().map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (hard, hold) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<Vec<String>, AppError> {
|
||||
let now = Utc::now();
|
||||
let mut restored_disabled_zones = Vec::new();
|
||||
for zone in zones.iter_mut() {
|
||||
let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; };
|
||||
let Some(deadline) = temporary_quick_thermostat_hard_deadline(session) else { continue; };
|
||||
if deadline > now { continue; }
|
||||
let finish_kind = session.finish_kind.clone();
|
||||
let restores_disabled = session.restore_zone_enabled == Some(false);
|
||||
reset_local_thermostat_override(zone);
|
||||
refresh_zone_runtime_target(zone, schedules, house_mode);
|
||||
zone.updated_at = now.clone();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": "deadline"
|
||||
}));
|
||||
if restores_disabled { restored_disabled_zones.push(zone.id.clone()); }
|
||||
}
|
||||
Ok(restored_disabled_zones)
|
||||
}
|
||||
|
||||
async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) {
|
||||
if zone.enabled || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; }
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
let should_stop = state.db.get_zone(&zone.id).ok().flatten()
|
||||
.map(|latest| !latest.enabled && latest.temporary_quick_thermostat.is_none() && latest.local_thermostat_power.is_none())
|
||||
.unwrap_or(false);
|
||||
if !should_stop { return; }
|
||||
if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool {
|
||||
let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; };
|
||||
let tolerance = session.tolerance_c.max(0.0);
|
||||
match session.temperature_operator.as_deref().unwrap_or("within") {
|
||||
"at_or_below" => current <= target + tolerance,
|
||||
"at_or_above" => current >= target - tolerance,
|
||||
_ => (current - target).abs() <= tolerance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a temperature-based temporary session from the freshly selected room sensor.
|
||||
/// Returns a completion reason when ownership should be handed back immediately.
|
||||
fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime<Utc>) -> Option<String> {
|
||||
let met = zone.temporary_quick_thermostat.as_ref()
|
||||
.filter(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable"))
|
||||
.map(|session| temporary_temperature_condition_met(zone, session))?;
|
||||
|
||||
let session = zone.temporary_quick_thermostat.as_mut()?;
|
||||
match session.finish_kind.as_str() {
|
||||
"temperature_reached" => {
|
||||
if met { return Some("temperature_reached".into()); }
|
||||
session.condition_started_at = None;
|
||||
}
|
||||
"temperature_stable" => {
|
||||
if !met {
|
||||
session.condition_started_at = None;
|
||||
return None;
|
||||
}
|
||||
let started = session.condition_started_at.get_or_insert(now.clone()).clone();
|
||||
if session.hold_seconds == 0 || now.signed_duration_since(started).num_seconds() >= session.hold_seconds as i64 {
|
||||
return Some("temperature_stable".into());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
for zone in zones.iter_mut() {
|
||||
@@ -669,11 +773,19 @@ fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], sched
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
|
||||
fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
|
||||
let now = Utc::now();
|
||||
Ok(state.db.list_zones()?.into_iter()
|
||||
.filter(local_thermostat_handback_is_active)
|
||||
.filter_map(|zone| zone.local_thermostat_resume_at)
|
||||
.filter_map(|zone| {
|
||||
let local = if local_thermostat_handback_is_active(&zone) { zone.local_thermostat_resume_at.clone() } else { None };
|
||||
let temporary = zone.temporary_quick_thermostat.as_ref().and_then(temporary_quick_thermostat_next_deadline);
|
||||
match (local, temporary) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
})
|
||||
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
|
||||
.min())
|
||||
}
|
||||
@@ -693,7 +805,17 @@ pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blo
|
||||
"home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(),
|
||||
_ => "local_thermostat".into(),
|
||||
};
|
||||
("local_thermostat", source, zone.local_thermostat_resume_at, if zone.local_thermostat_power == Some(false) { "Local thermostat is explicitly off".into() } else { "Local thermostat owns the zone".into() })
|
||||
let resume_at = zone.temporary_quick_thermostat.as_ref()
|
||||
.and_then(temporary_quick_thermostat_next_deadline)
|
||||
.or(zone.local_thermostat_resume_at.clone());
|
||||
let reason = if zone.local_thermostat_power == Some(false) {
|
||||
"Local thermostat is explicitly off".into()
|
||||
} else if zone.temporary_quick_thermostat.is_some() {
|
||||
"Temporary Quick Thermostat owns the zone".into()
|
||||
} else {
|
||||
"Local thermostat owns the zone".into()
|
||||
};
|
||||
("local_thermostat", source, resume_at, reason)
|
||||
} else if blocked_by_group {
|
||||
("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string())
|
||||
} else {
|
||||
@@ -1146,6 +1268,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// before the house-power early return so the hand-back still happens while the master
|
||||
// is off; no physical state is restored here, only automation ownership.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
||||
@@ -1286,11 +1409,32 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
let condition_now = zone.updated_at.clone();
|
||||
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(&mut zone, condition_now) {
|
||||
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
||||
reset_local_thermostat_override(&mut zone);
|
||||
refresh_zone_runtime_target(&mut zone, &schedules, &settings.house_mode);
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
if persisted_zone.temporary_quick_thermostat.is_none() {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await;
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
|
||||
}));
|
||||
}
|
||||
state.wake_zone_control();
|
||||
continue;
|
||||
}
|
||||
|
||||
// A disabled thermostat zone is completely outside normal controller ownership.
|
||||
// Keep its sensors fresh, but do not let group state, schedules or thermostat
|
||||
// modulation touch the unit. Manual control from the technical Devices view may
|
||||
// therefore remain active until the zone is explicitly enabled again.
|
||||
if !zone.enabled {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
|
||||
}
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
@@ -2523,7 +2667,7 @@ mod tests {
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None,
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None,
|
||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
|
||||
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
@@ -2603,6 +2747,65 @@ mod tests {
|
||||
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
|
||||
}
|
||||
|
||||
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
|
||||
TemporaryQuickThermostat {
|
||||
finish_kind: "temperature_stable".into(),
|
||||
started_at: now,
|
||||
restore_zone_enabled: Some(true),
|
||||
expires_at: None,
|
||||
temperature_target: Some(23.0),
|
||||
temperature_operator: Some("within".into()),
|
||||
tolerance_c: 0.3,
|
||||
hold_seconds: 3600,
|
||||
condition_started_at: None,
|
||||
safety_expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_quick_thermostat_restores_previous_zone_enabled_state() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.enabled = true;
|
||||
zone.local_thermostat_power = Some(true);
|
||||
let mut session = temporary_session(now);
|
||||
session.restore_zone_enabled = Some(false);
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
|
||||
assert!(reset_local_thermostat_override(&mut zone));
|
||||
assert!(!zone.enabled);
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert!(zone.temporary_quick_thermostat.is_none());
|
||||
assert!(zone.manual_setpoint.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_stable_condition_requires_continuous_hold_time() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(23.2);
|
||||
let mut session = temporary_session(now.clone());
|
||||
session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone()).is_none());
|
||||
assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2)), Some("temperature_stable".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_stable_condition_resets_when_temperature_leaves_range() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(24.0);
|
||||
let mut session = temporary_session(now.clone());
|
||||
session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now).is_none());
|
||||
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_off_restarts_backend_handback_deadline() {
|
||||
let mut zone = test_zone("device");
|
||||
|
||||
@@ -37,6 +37,7 @@ fn default_night_start() -> String { "22:00".into() }
|
||||
fn default_night_end() -> String { "06:00".into() }
|
||||
fn default_night_max_fan_speed() -> u8 { 1 }
|
||||
fn default_group_power_enabled() -> bool { true }
|
||||
fn default_temporary_tolerance() -> f64 { 0.3 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
@@ -268,6 +269,56 @@ impl From<&Device> for ManualDeviceBaseline {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemporaryQuickThermostat {
|
||||
/// duration | until | temperature_reached | temperature_stable | schedule_boundary
|
||||
pub finish_kind: String,
|
||||
pub started_at: DateTime<Utc>,
|
||||
/// Zone automation enabled-state from before the temporary session. A temporary
|
||||
/// Quick Thermostat may run even when normal automation was disabled, then restore it.
|
||||
#[serde(default)]
|
||||
pub restore_zone_enabled: Option<bool>,
|
||||
/// Hard end for duration/until/schedule-boundary modes.
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Temperature condition used by reached/stable modes.
|
||||
#[serde(default)]
|
||||
pub temperature_target: Option<f64>,
|
||||
/// within | at_or_below | at_or_above
|
||||
#[serde(default)]
|
||||
pub temperature_operator: Option<String>,
|
||||
#[serde(default = "default_temporary_tolerance")]
|
||||
pub tolerance_c: f64,
|
||||
/// Continuous in-condition time required by temperature_stable.
|
||||
#[serde(default)]
|
||||
pub hold_seconds: u64,
|
||||
/// Set only while the latest room samples continuously satisfy the condition.
|
||||
#[serde(default)]
|
||||
pub condition_started_at: Option<DateTime<Utc>>,
|
||||
/// Optional fail-safe for temperature-based modes.
|
||||
#[serde(default)]
|
||||
pub safety_expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemporaryQuickThermostatRequest {
|
||||
pub finish_kind: String,
|
||||
#[serde(default)]
|
||||
pub duration_minutes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub target_temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub temperature_operator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tolerance_c: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub hold_minutes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_duration_minutes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Zone {
|
||||
pub id: String,
|
||||
@@ -358,6 +409,11 @@ pub struct Zone {
|
||||
/// the current group/schedule state is evaluated again.
|
||||
#[serde(default)]
|
||||
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
|
||||
/// Separate, user-defined temporary Quick Thermostat session. This is intentionally
|
||||
/// independent from local_thermostat_resume_at, which belongs to the local-OFF
|
||||
/// hand-back mechanism.
|
||||
#[serde(default)]
|
||||
pub temporary_quick_thermostat: Option<TemporaryQuickThermostat>,
|
||||
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
|
||||
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
|
||||
#[serde(default)]
|
||||
@@ -463,6 +519,12 @@ pub struct ZoneControlPatch {
|
||||
/// Return a locally forced quick thermostat to normal group/schedule ownership.
|
||||
#[serde(default)]
|
||||
pub clear_local_thermostat_override: Option<bool>,
|
||||
/// Start or replace a temporary Quick Thermostat session.
|
||||
#[serde(default)]
|
||||
pub temporary_quick_thermostat: Option<TemporaryQuickThermostatRequest>,
|
||||
/// Stop only the temporary Quick Thermostat session and return to automation.
|
||||
#[serde(default)]
|
||||
pub clear_temporary_quick_thermostat: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+174
-5
@@ -58,6 +58,16 @@ const formatCountdown = seconds => {
|
||||
const remainder = safe % 60;
|
||||
return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
|
||||
};
|
||||
const formatExtendedCountdown = seconds => {
|
||||
if (!Number.isFinite(seconds)) return '∞';
|
||||
const safe = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(safe / 3600);
|
||||
const minutes = Math.floor((safe % 3600) / 60);
|
||||
const remainder = safe % 60;
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`
|
||||
: `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
|
||||
};
|
||||
function updateLocalThermostatCountdowns() {
|
||||
$$('[data-local-resume-countdown]').forEach(node => {
|
||||
const remaining = localResumeSeconds(node.dataset.localResumeCountdown);
|
||||
@@ -66,6 +76,56 @@ function updateLocalThermostatCountdowns() {
|
||||
: tr('zones.localThermostatResuming');
|
||||
});
|
||||
}
|
||||
function temporarySessionStatus(zone) {
|
||||
const session = zone?.temporary_quick_thermostat;
|
||||
if (!session) return null;
|
||||
const now = Date.now();
|
||||
const secondsUntil = value => {
|
||||
const timestamp = value ? new Date(value).getTime() : NaN;
|
||||
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - now) / 1000)) : null;
|
||||
};
|
||||
const safetyRemaining = secondsUntil(session.safety_expires_at);
|
||||
const hardRemaining = secondsUntil(session.expires_at);
|
||||
const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint);
|
||||
const targetText = Number.isFinite(target) ? target.toFixed(1) : '--';
|
||||
const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1);
|
||||
const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort';
|
||||
const condition = tr(operatorKey, {target:targetText, tolerance});
|
||||
|
||||
if (session.finish_kind === 'temperature_stable') {
|
||||
const started = session.condition_started_at ? new Date(session.condition_started_at).getTime() : NaN;
|
||||
const holdSeconds = Number(session.hold_seconds || 0);
|
||||
if (Number.isFinite(started)) {
|
||||
const holdRemaining = Math.max(0, Math.ceil((started + holdSeconds * 1000 - now) / 1000));
|
||||
const effectiveRemaining = safetyRemaining == null ? holdRemaining : Math.min(holdRemaining, safetyRemaining);
|
||||
return {countdown:formatExtendedCountdown(effectiveRemaining), detail:tr('zones.temporaryHoldingStatus', {condition}), kind:session.finish_kind};
|
||||
}
|
||||
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingStable', {condition}), kind:session.finish_kind};
|
||||
}
|
||||
if (session.finish_kind === 'temperature_reached') {
|
||||
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingReached', {condition}), kind:session.finish_kind};
|
||||
}
|
||||
const detailKey = session.finish_kind === 'schedule_boundary' ? 'zones.temporaryUntilScheduleStatus' : 'zones.temporaryTimeStatus';
|
||||
return {countdown:formatExtendedCountdown(hardRemaining), detail:tr(detailKey), kind:session.finish_kind};
|
||||
}
|
||||
function updateTemporaryThermostatCountdowns() {
|
||||
$$('[data-temporary-countdown-zone]').forEach(node => {
|
||||
const zone = app.zones.find(item => item.id === node.dataset.temporaryCountdownZone);
|
||||
const status = temporarySessionStatus(zone);
|
||||
if (!status) return;
|
||||
node.textContent = status.countdown;
|
||||
node.title = status.detail;
|
||||
});
|
||||
const dialog = $('#temporaryThermostatDialog');
|
||||
if (dialog?.open) {
|
||||
const zone = app.zones.find(item => item.id === $('#temporaryThermostatForm')?.elements.zone_id.value);
|
||||
const status = temporarySessionStatus(zone);
|
||||
if (status) {
|
||||
$('#temporaryThermostatActiveCountdown').textContent = status.countdown;
|
||||
$('#temporaryThermostatActiveDetail').textContent = status.detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity;
|
||||
|
||||
function updateConnectionIndicator(status) {
|
||||
@@ -756,11 +816,15 @@ function zoneCard(zone, detailed = true) {
|
||||
const deviceManualOverride = zone.device_manual_override === true;
|
||||
const localThermostatPower = zone.local_thermostat_power;
|
||||
const localThermostatResumeAt = zone.local_thermostat_resume_at || null;
|
||||
const temporarySession = zone.temporary_quick_thermostat || null;
|
||||
const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null;
|
||||
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
|
||||
const override = deviceManualOverride
|
||||
? (zone.device_manual_override_until
|
||||
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
|
||||
: tr('zones.manualDeviceNoBoundary'))
|
||||
: temporarySession
|
||||
? tr('zones.temporaryOverride', {time:temporaryStatus?.countdown || '—'})
|
||||
: localThermostatOverride
|
||||
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
|
||||
: (zone.manual_override_until
|
||||
@@ -768,21 +832,24 @@ function zoneCard(zone, detailed = true) {
|
||||
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
|
||||
const manualTakeover = deviceManualOverride
|
||||
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="device" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
|
||||
: localThermostatOverride
|
||||
: localThermostatOverride && !temporarySession
|
||||
? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong>${localThermostatPower === false && localThermostatResumeAt
|
||||
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}">${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}</p>`
|
||||
: `<p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p>`}</div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
|
||||
: '';
|
||||
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
|
||||
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
|
||||
const localThermostatPowerControl = `<div class="zone-local-thermostat-power"><button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}>${esc(tr(localRequestedOn ? 'zones.turnThermostatOff' : 'zones.turnThermostatOn'))}</button></div>`;
|
||||
const localThermostatPowerControl = `<button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}><span aria-hidden="true">⏻</span><b>${esc(tr(localRequestedOn ? 'common.on' : 'common.off'))}</b></button>`;
|
||||
const temporaryThermostatControl = temporarySession
|
||||
? `<button type="button" class="temporary-thermostat-toggle active" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><span aria-hidden="true">⏱</span><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
|
||||
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><span aria-hidden="true">⏱</span><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
|
||||
|
||||
if (detailed) {
|
||||
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
|
||||
const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup');
|
||||
const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly');
|
||||
return `<article class="list-card zone-config-card ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-config="${esc(zone.id)}">
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}" title="${esc(tr('zones.automationToggleHint'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button></div>
|
||||
<div class="zone-config-status">
|
||||
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b>→</b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}</strong></div></div>
|
||||
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
|
||||
@@ -800,8 +867,7 @@ function zoneCard(zone, detailed = true) {
|
||||
}
|
||||
|
||||
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button></div>
|
||||
${localThermostatPowerControl}
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><div class="zone-quick-actions">${localThermostatPowerControl}${temporaryThermostatControl}</div></div>
|
||||
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
|
||||
<div class="preset-row quick-control-row quick-control-row-4">
|
||||
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
|
||||
@@ -1314,6 +1380,64 @@ function populateZone(id) {
|
||||
form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog');
|
||||
}
|
||||
|
||||
const dateTimeLocalValue = value => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return '';
|
||||
const pad = number => String(number).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
function updateTemporaryThermostatFields() {
|
||||
const form = $('#temporaryThermostatForm'); if (!form) return;
|
||||
const kind = form.finish_kind.value;
|
||||
const duration = $('#temporaryDurationFields');
|
||||
const until = $('#temporaryUntilFields');
|
||||
const temperature = $('#temporaryTemperatureFields');
|
||||
const schedule = $('#temporaryScheduleFields');
|
||||
duration.hidden = kind !== 'duration';
|
||||
until.hidden = kind !== 'until';
|
||||
temperature.hidden = !['temperature_reached','temperature_stable'].includes(kind);
|
||||
schedule.hidden = kind !== 'schedule_boundary';
|
||||
$('#temporaryHoldField').hidden = kind !== 'temperature_stable';
|
||||
form.duration_minutes.required = kind === 'duration';
|
||||
form.until.required = kind === 'until';
|
||||
form.hold_minutes.required = kind === 'temperature_stable';
|
||||
}
|
||||
|
||||
function populateTemporaryThermostat(id) {
|
||||
const zone = app.zones.find(item => item.id === id); if (!zone) return;
|
||||
const form = $('#temporaryThermostatForm');
|
||||
form.reset();
|
||||
form.elements.zone_id.value = zone.id;
|
||||
form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1);
|
||||
form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000));
|
||||
const session = zone.temporary_quick_thermostat;
|
||||
if (session) {
|
||||
form.finish_kind.value = session.finish_kind || 'duration';
|
||||
if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1);
|
||||
if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator;
|
||||
form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1);
|
||||
if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60));
|
||||
if (session.expires_at) {
|
||||
form.until.value = dateTimeLocalValue(session.expires_at);
|
||||
if (session.finish_kind === 'duration') form.duration_minutes.value = Math.max(1, Math.ceil((new Date(session.expires_at).getTime() - Date.now()) / 60000));
|
||||
}
|
||||
if (session.safety_expires_at) {
|
||||
form.max_duration_minutes.value = Math.max(1, Math.ceil((new Date(session.safety_expires_at).getTime() - Date.now()) / 60000));
|
||||
} else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) {
|
||||
form.max_duration_minutes.value = '';
|
||||
}
|
||||
}
|
||||
updateTemporaryThermostatFields();
|
||||
const active = $('#temporaryThermostatActive');
|
||||
active.hidden = !session;
|
||||
$('#temporaryThermostatStop').dataset.id = zone.id;
|
||||
$('#temporaryThermostatSubmit').textContent = tr(session ? 'zones.temporaryUpdate' : 'zones.temporaryStart');
|
||||
updateTemporaryThermostatCountdowns();
|
||||
openDialog('temporaryThermostatDialog');
|
||||
updateTemporaryThermostatCountdowns();
|
||||
}
|
||||
|
||||
function populateSchedule(id) {
|
||||
const item = app.schedules.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#scheduleForm'); form.reset(); fillSelects();
|
||||
@@ -1824,6 +1948,7 @@ document.addEventListener('click', async event => {
|
||||
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value});
|
||||
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
|
||||
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
|
||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
||||
@@ -1984,6 +2109,49 @@ $('#zoneForm').addEventListener('submit', async event => {
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
$('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields);
|
||||
$$('[data-temporary-duration]').forEach(button => button.addEventListener('click', () => {
|
||||
const form = $('#temporaryThermostatForm');
|
||||
form.duration_minutes.value = button.dataset.temporaryDuration;
|
||||
}));
|
||||
|
||||
$('#temporaryThermostatStop')?.addEventListener('click', async event => {
|
||||
const id = event.currentTarget.dataset.id; if (!id) return;
|
||||
try {
|
||||
event.currentTarget.disabled = true;
|
||||
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{clear_temporary_quick_thermostat:true}});
|
||||
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
|
||||
$('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { event.currentTarget.disabled = false; }
|
||||
});
|
||||
|
||||
$('#temporaryThermostatForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const request = {finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)};
|
||||
if (raw.finish_kind === 'duration') request.duration_minutes = Number(raw.duration_minutes);
|
||||
if (raw.finish_kind === 'until') {
|
||||
const until = new Date(raw.until);
|
||||
if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true);
|
||||
request.until = until.toISOString();
|
||||
}
|
||||
if (['temperature_reached','temperature_stable'].includes(raw.finish_kind)) {
|
||||
request.temperature_operator = raw.temperature_operator;
|
||||
request.tolerance_c = parseDecimal(raw.tolerance_c);
|
||||
if (raw.finish_kind === 'temperature_stable') request.hold_minutes = Number(raw.hold_minutes);
|
||||
const safety = Number(raw.max_duration_minutes);
|
||||
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
|
||||
}
|
||||
try {
|
||||
$('#temporaryThermostatSubmit').disabled = true;
|
||||
const zone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}});
|
||||
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
|
||||
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStarted'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { $('#temporaryThermostatSubmit').disabled = false; }
|
||||
});
|
||||
|
||||
$('#groupForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
@@ -2293,4 +2461,5 @@ async function startApplication() {
|
||||
}
|
||||
|
||||
setInterval(updateLocalThermostatCountdowns, 1000);
|
||||
setInterval(updateTemporaryThermostatCountdowns, 1000);
|
||||
startApplication().catch(error => console.error('Application startup failed:', error));
|
||||
|
||||
@@ -402,6 +402,48 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="temporaryThermostatDialog">
|
||||
<form method="dialog" id="temporaryThermostatForm" class="dialog-form temporary-thermostat-form">
|
||||
<input type="hidden" name="zone_id">
|
||||
<div class="dialog-head"><div><span class="eyebrow" data-i18n="zones.quickThermostat">Szybki termostat</span><h2 data-i18n="zones.temporaryTitle">Włącz czasowo</h2></div><button type="button" data-close>×</button></div>
|
||||
<div id="temporaryThermostatActive" class="temporary-active-summary" hidden>
|
||||
<div><small data-i18n="zones.temporaryActive">Aktywny tryb czasowy</small><strong id="temporaryThermostatActiveCountdown">—</strong><span id="temporaryThermostatActiveDetail"></span></div>
|
||||
<button type="button" class="danger" id="temporaryThermostatStop" data-i18n="zones.temporaryStop">Zakończ teraz</button>
|
||||
</div>
|
||||
<label><span data-i18n="zones.temporaryTarget">Temperatura szybkiego termostatu</span><input type="number" name="target_temperature" min="8" max="30" step="0.5" required></label>
|
||||
<label><span data-i18n="zones.temporaryFinishRule">Kiedy wrócić do automatyki</span><select name="finish_kind" id="temporaryFinishKind">
|
||||
<option value="duration" data-i18n="zones.temporaryDuration">Po określonym czasie</option>
|
||||
<option value="until" data-i18n="zones.temporaryUntil">O konkretnej dacie i godzinie</option>
|
||||
<option value="temperature_reached" data-i18n="zones.temporaryReached">Po osiągnięciu warunku temperatury</option>
|
||||
<option value="temperature_stable" data-i18n="zones.temporaryStable">Po utrzymaniu warunku temperatury</option>
|
||||
<option value="schedule_boundary" data-i18n="zones.temporaryScheduleBoundary">Przy następnym przejściu harmonogramu</option>
|
||||
</select></label>
|
||||
|
||||
<div id="temporaryDurationFields" class="temporary-rule-fields">
|
||||
<label><span data-i18n="zones.temporaryDurationMinutes">Czas działania (min)</span><input type="number" name="duration_minutes" min="1" max="14400" value="60"></label>
|
||||
<div class="temporary-presets"><button type="button" data-temporary-duration="30">30 min</button><button type="button" data-temporary-duration="60">1 h</button><button type="button" data-temporary-duration="120">2 h</button><button type="button" data-temporary-duration="240">4 h</button></div>
|
||||
</div>
|
||||
|
||||
<div id="temporaryUntilFields" class="temporary-rule-fields" hidden>
|
||||
<label><span data-i18n="zones.temporaryUntilDate">Wyłącz o</span><input type="datetime-local" name="until"></label>
|
||||
</div>
|
||||
|
||||
<div id="temporaryTemperatureFields" class="temporary-rule-fields" hidden>
|
||||
<div class="two"><label><span data-i18n="zones.temporaryCondition">Warunek</span><select name="temperature_operator">
|
||||
<option value="within" data-i18n="zones.temporaryWithin">w zakresie celu ± tolerancja</option>
|
||||
<option value="at_or_below" data-i18n="zones.temporaryAtOrBelow">temperatura ≤ cel</option>
|
||||
<option value="at_or_above" data-i18n="zones.temporaryAtOrAbove">temperatura ≥ cel</option>
|
||||
</select></label><label><span data-i18n="zones.temporaryTolerance">Tolerancja °C</span><input type="number" name="tolerance_c" min="0.1" max="3" step="0.1" value="0.3"></label></div>
|
||||
<label id="temporaryHoldField"><span data-i18n="zones.temporaryHoldMinutes">Warunek musi trwać (min)</span><input type="number" name="hold_minutes" min="1" max="1440" value="60"></label>
|
||||
<label><span data-i18n="zones.temporarySafetyLimit">Limit bezpieczeństwa (min, opcjonalnie)</span><input type="number" name="max_duration_minutes" min="1" max="14400" value="240"></label>
|
||||
<p class="field-note" data-i18n="zones.temporaryStableHint">Jeśli temperatura wyjdzie poza warunek, licznik utrzymania zeruje się i zaczyna od nowa po ponownym wejściu w zakres.</p>
|
||||
</div>
|
||||
|
||||
<div id="temporaryScheduleFields" class="temporary-rule-fields" hidden><p class="field-note" data-i18n="zones.temporaryScheduleHint">Szybki termostat zakończy się przy najbliższej zmianie aktywnego wpisu harmonogramu tej strefy.</p></div>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button class="primary" type="submit" id="temporaryThermostatSubmit" data-i18n="zones.temporaryStart">Włącz czasowo</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="groupDialog">
|
||||
<form method="dialog" id="groupForm" class="dialog-form">
|
||||
<input type="hidden" name="id">
|
||||
|
||||
+22
-4
@@ -786,11 +786,26 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36
|
||||
.quick-device-control .temperature-control { gap:12px; padding:12px 0 10px; }
|
||||
.quick-device-control .current-line { margin:-3px 0 10px; font-size:11px; }
|
||||
.quick-device-control .device-toggles.quick-control-row { margin-top:1px; }
|
||||
.quick-thermostat-control .zone-local-thermostat-power { display:flex; margin-top:6px; }
|
||||
.quick-thermostat-control .zone-local-thermostat-power .zone-unit-power-toggle { width:100%; min-height:30px; font-size:10px; }
|
||||
.quick-thermostat-control .zone-local-thermostat-power .zone-unit-power-toggle.active { color:var(--accent-text); background:var(--accent); font-weight:800; }
|
||||
.quick-thermostat-control .zone-quick-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:5px; min-width:128px; }
|
||||
.quick-thermostat-control .zone-quick-actions button { display:flex; align-items:center; justify-content:center; gap:5px; min-width:0; min-height:30px; padding:5px 7px; font-size:10px; white-space:nowrap; }
|
||||
.quick-thermostat-control .zone-quick-actions button span { font-size:13px; line-height:1; }
|
||||
.quick-thermostat-control .zone-quick-actions button b { overflow:hidden; font-size:10px; font-variant-numeric:tabular-nums; text-overflow:ellipsis; }
|
||||
.quick-thermostat-control .zone-unit-power-toggle.active { color:var(--accent-text); background:var(--accent); font-weight:800; }
|
||||
.quick-thermostat-control .temporary-thermostat-toggle.active { border-color:color-mix(in srgb,var(--warning) 55%,var(--line)); color:var(--warning); background:var(--warning-soft); }
|
||||
.quick-thermostat-control .thermostat-main { margin:8px 0 7px; }
|
||||
.quick-thermostat-control .zone-enable-toggle { min-height:28px; padding:4px 8px; font-size:10px; }
|
||||
.zone-config-card .zone-enable-toggle { min-height:28px; padding:4px 8px; font-size:10px; }
|
||||
|
||||
.temporary-thermostat-form { gap:16px; }
|
||||
.temporary-rule-fields { display:grid; gap:12px; padding:13px; border:1px solid var(--line); border-radius:14px; background:var(--surface-muted); }
|
||||
.temporary-rule-fields[hidden] { display:none; }
|
||||
.temporary-presets { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:5px; }
|
||||
.temporary-presets button { min-height:32px; padding:6px; font-size:10px; }
|
||||
.temporary-active-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 13px; border:1px solid color-mix(in srgb,var(--warning) 45%,var(--line)); border-radius:14px; background:var(--warning-soft); }
|
||||
.temporary-active-summary[hidden] { display:none; }
|
||||
.temporary-active-summary > div { display:grid; gap:3px; min-width:0; }
|
||||
.temporary-active-summary small, .temporary-active-summary span { color:var(--muted); font-size:10px; line-height:1.35; }
|
||||
.temporary-active-summary strong { color:var(--warning); font-size:22px; font-variant-numeric:tabular-nums; letter-spacing:.02em; }
|
||||
.temporary-active-summary .danger { flex:0 0 auto; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
[data-view="dashboard"] .hero { min-height:145px; padding:20px; }
|
||||
@@ -807,6 +822,9 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36
|
||||
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.dashboard-disclosure > summary { padding:12px 13px; }
|
||||
.dashboard-disclosure-body { padding:10px; }
|
||||
.quick-thermostat-control .list-card-head { align-items:center; }
|
||||
.quick-thermostat-control .zone-quick-actions { min-width:116px; }
|
||||
.temporary-presets { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
}
|
||||
|
||||
/* 0.6.9 dashboard density and fit fixes */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v082-ui-i18n-ha-freshness';
|
||||
const CACHE = 'gree-controller-v083-temporary-quick-thermostat';
|
||||
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
|
||||
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
|
||||
const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];
|
||||
|
||||
Reference in New Issue
Block a user