From d1597952675f62e7b413250e15ef44c48a2a51ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 28 Aug 2026 09:44:39 +0200 Subject: [PATCH] v0.8.3 --- BUILD_REPORT.md | 35 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 32 +-- README.md | 12 +- docs/API.md | 15 ++ .../gree_controller/manifest.json | 2 +- lang/en.json | 41 +++- lang/pl.json | 41 +++- src/api.rs | 118 +++++++++- src/engine.rs | 219 +++++++++++++++++- src/models.rs | 62 +++++ web/app.js | 179 +++++++++++++- web/index.html | 42 ++++ web/styles.css | 26 ++- web/sw.js | 2 +- 16 files changed, 782 insertions(+), 48 deletions(-) diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 98f7c8d..4b7c89a 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 56837b9..3feb648 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index c00182a..defd245 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index e56fbbd..1d0c03e 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index a55e989..7895495 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/docs/API.md b/docs/API.md index 0b4fcb8..6d48843 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 110a21b..c96d5e1 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -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", diff --git a/lang/en.json b/lang/en.json index 2b85b2e..9b27c1c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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." } } diff --git a/lang/pl.json b/lang/pl.json index 80945e4..b758b86 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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." } } diff --git a/src/api.rs b/src/api.rs index d3e8bf6..cb9bd07 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, Path(id): Path, 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 { 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)?; diff --git a/src/engine.rs b/src/engine.rs index 52a9e62..ed594a2 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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> { + 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> { + 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, 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) -> Option { + 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, AppError> { +fn next_zone_control_deadline_delay(state: &AppState) -> Result, 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) -> 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"); diff --git a/src/models.rs b/src/models.rs index 5665f1d..215401d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, + /// 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, + /// Hard end for duration/until/schedule-boundary modes. + #[serde(default)] + pub expires_at: Option>, + /// Temperature condition used by reached/stable modes. + #[serde(default)] + pub temperature_target: Option, + /// within | at_or_below | at_or_above + #[serde(default)] + pub temperature_operator: Option, + #[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>, + /// Optional fail-safe for temperature-based modes. + #[serde(default)] + pub safety_expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemporaryQuickThermostatRequest { + pub finish_kind: String, + #[serde(default)] + pub duration_minutes: Option, + #[serde(default)] + pub until: Option>, + #[serde(default)] + pub target_temperature: Option, + #[serde(default)] + pub temperature_operator: Option, + #[serde(default)] + pub tolerance_c: Option, + #[serde(default)] + pub hold_minutes: Option, + #[serde(default)] + pub max_duration_minutes: Option, +} + #[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>, + /// 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, /// 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, + /// Start or replace a temporary Quick Thermostat session. + #[serde(default)] + pub temporary_quick_thermostat: Option, + /// Stop only the temporary Quick Thermostat session and return to automation. + #[serde(default)] + pub clear_temporary_quick_thermostat: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/web/app.js b/web/app.js index 030aaf4..75af655 100644 --- a/web/app.js +++ b/web/app.js @@ -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 ? `
${esc(tr('zones.manualDeviceControl'))}

${esc(tr('zones.manualDeviceDescription'))}

` - : localThermostatOverride + : localThermostatOverride && !temporarySession ? `
${esc(tr('zones.localThermostatControl'))}${localThermostatPower === false && localThermostatResumeAt ? `

${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}

` : `

${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}

`}
` : ''; const localPowerDisabled = !device || device.enabled === false ? ' disabled' : ''; const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power); - const localThermostatPowerControl = `
`; + const localThermostatPowerControl = ``; + const temporaryThermostatControl = temporarySession + ? `` + : ``; 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 `
-
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

${esc(state)}
+
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
@@ -800,8 +867,7 @@ function zoneCard(zone, detailed = true) { } return `
-

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}

- ${localThermostatPowerControl} +

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}

${localThermostatPowerControl}${temporaryThermostatControl}
${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target)?target.toFixed(1):'--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
${['auto','comfort','sleep','away'].map(preset=>``).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)); diff --git a/web/index.html b/web/index.html index 0310a88..5d545a1 100644 --- a/web/index.html +++ b/web/index.html @@ -402,6 +402,48 @@ + +
+ +
Szybki termostat

Włącz czasowo

+ + + + +
+ +
+
+ + + + + + +
+
+
+
diff --git a/web/styles.css b/web/styles.css index 77f178d..a45e7d4 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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 */ diff --git a/web/sw.js b/web/sw.js index 9807e85..5537d84 100644 --- a/web/sw.js +++ b/web/sw.js @@ -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')];