From 62514258d375302e9a33fc6c309e8c8dd1f7095f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 27 Aug 2026 17:17:05 +0200 Subject: [PATCH] v0.7.12 --- BUILD_REPORT.md | 26 ++++- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 30 +++--- README.md | 12 ++- docs/API.md | 4 +- home-assistant/README.md | 2 +- .../gree_controller/climate.py | 1 + .../gree_controller/manifest.json | 2 +- lang/en.json | 7 +- lang/pl.json | 7 +- src/api.rs | 24 +++-- src/engine.rs | 96 ++++++++++++++++++- src/models.rs | 6 ++ web/app.js | 25 ++++- web/sw.js | 2 +- 16 files changed, 205 insertions(+), 43 deletions(-) diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index ab5dac9..0f7b702 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,8 +1,8 @@ -# GREE Controller v0.7.11 - build and validation report +# GREE Controller v0.7.12 - build and validation report ## Scope -Version 0.7.11 includes the previous climate-group, whole-house and zone-profile fixes plus a strict separation between local quick-thermostat ownership and true physical/manual-device takeover. Global controls are consolidated, group control stays immediately below them, and lower-priority dashboard sections are collapsed on demand. +Version 0.7.12 adds a timed hand-back for per-zone Quick Thermostat OFF while preserving the strict separation between local thermostat ownership and true physical/manual-device takeover. After 15 minutes the zone returns to the current group/schedule logic instead of restoring stale physical state. ## Implemented changes @@ -436,3 +436,25 @@ Validation for this package is recorded after the final archive/manifest checks. - Cargo package and Home Assistant manifest report version `0.7.11`; PWA cache metadata was rotated for this release. - The reported Rust E0505 test case was corrected with borrow-only `std::slice::from_ref(&zone)` calls. - This packaging environment does not provide `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment. +## 2026-08-27 v0.7.12 timed local-thermostat resume + +- Added persisted `local_thermostat_resume_at` to each zone. +- Local Quick Thermostat ON has no timeout; local OFF powers the unit down immediately and schedules automatic automation hand-back after exactly 15 minutes. +- The control loop now sleeps only until the earlier of its normal interval or the nearest local-resume deadline, so the 15-minute hand-back is not delayed by a long zone interval and remains restart-safe. +- Expiry clears local power ownership and quick preset/setpoint overrides, then re-evaluates the current group, house mode, schedule, temperature and hysteresis. It never restores a stale physical ON/OFF snapshot. +- Physical/pilot `device_manual_override` is intentionally untouched by the local timer. +- Web UI displays a live `MM:SS` countdown and **Resume now** during the local-OFF window. +- Persisted 0.7.10/0.7.11 local-OFF records without a deadline receive a fresh 15-minute deadline on first control cycle after upgrade. +- Home Assistant zone attributes expose `local_thermostat_resume_at`. +- Added a unit test that verifies local hand-back clears local quick state without clearing a real physical-device takeover. + +## Final validation for v0.7.12 + +- Web JavaScript passes `node --check`. +- Home Assistant modules and helper scripts pass `python3 -m py_compile`. +- JSON and TOML metadata parse successfully; Polish and English translation packs have identical key sets. +- Shell scripts pass `bash -n`; HTML parses and CSS braces are balanced. +- Source references for `local_thermostat_resume_at` are consistent across model, API, engine, Web UI, control-plan output and Home Assistant attributes. +- Version metadata is aligned at `0.7.12` and the PWA cache key is rotated. +- This packaging environment still has no `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment. + diff --git a/Cargo.lock b/Cargo.lock index b21c53c..4f82826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.7.11" +version = "0.7.12" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index e1f139f..b13b076 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.7.11" +version = "0.7.12" 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 f2089a7..5148359 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,29 +1,29 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -d660312751ad5ddbc730d25ddd2181b1c4c8be4a8da18e2b23eaa39ca00c1d63 ./BUILD_REPORT.md -83f9d4ee92b722c3f81bb739a55d4b2d66bbd09276c31876cbf4f610a812c280 ./Cargo.lock -d42b2cdbf06555dd327405c9f84c14b7f222faa545c4e6f5d9fde8b6e9ad55ac ./Cargo.toml +b2539a47c2594c4abfb54965c01a0bac18f6bcbd70ed3f546201819ee793e5e1 ./BUILD_REPORT.md +7e3367a68ba90e5e5139fde8842cf5d81ac87a154aeda44a8331be946207da7d ./Cargo.lock +e97cb5f6ccf91a32b2c97560140f1ea0314b27d61039fbd03564ceacfab7fe31 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -f846576df3ace004a7c4f3cd104242cbc38814b383938d6d914ad3ebc085c71e ./README.md +8a7844b5774ded9e19917d27c509c891062b4e06eb79e811f1c382fd31121d37 ./README.md f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs -38bbc16b8350ca5f70646b40d1b7d0a9231425208c99f4f2d17979431c0c289a ./docs/API.md +61977236e655ede9be9fb3ac1342fca3ca5f466f41ded5385515bc5c86a974b9 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md 2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md 7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md 28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md -8cd55020fd740bc4b26ee6215218ea9c49ba37e1566e2128831c004bd35c52a2 ./home-assistant/README.md +f617d0b003a6786e4da144b12134451aba6641d91d4e9aa690d5c0009de3a83c ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py -15b7430190bec9b82690d4073b1371d78746d01931874d45a05858ea4334cc37 ./home-assistant/custom_components/gree_controller/climate.py +3d0169ddabe6c664f71e76582995ac749c35214ca717654d296f4da8b909f2bf ./home-assistant/custom_components/gree_controller/climate.py 5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py b065b61ef1adafe96aae3e47bff3e8b0bba57796fdfac1503669046beb1c0157 ./home-assistant/custom_components/gree_controller/coordinator.py 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 -7a0171c08b8edd44369416b067c0a267b88c8fb28fbf34125385d0d6c9e308cb ./home-assistant/custom_components/gree_controller/manifest.json +ef77626f30454babda4c8fc3c80196a40d500b07db99d3f0640c3460fbe7b8e9 ./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 @@ -32,8 +32,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./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 -cc404db7cac21c39c0c5d61ead78a36835beb0fa599db1da02df1b69044dbc3b ./lang/en.json -ae856c40dc495337f649f347cfbf1842f8a25ca803c3413d9ab2b6c181427a2a ./lang/pl.json +f8401fc91cce6b11a62c1624c3c67f075f145870c4caa13a295701cb2253fa2a ./lang/en.json +cedd112847c3053dd3e130f77d1ed033d7bce70dff159a37d0ec9b5165e9dd20 ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -46,15 +46,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -9a9c8267a8b4dab192b1d97d2ef3a255338e2a3831f7bf33ce4b18fb85f74f19 ./src/api.rs +c5d7839844b823966f2dac3f9a89e2b323a9c349840cb8a0edc571f1b055ae31 ./src/api.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -2c7f21d18d04d63691f0856b895fd29cd625861220773dce9e74a52d5d241619 ./src/engine.rs +b2ca8bc21f80734873a56792bdced9c85f7cdb6cd1c00d03ab161a8dd042d288 ./src/engine.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs 081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs 4ae0cc270938a9626148490c04c69b01cda75c30547a63a6a1b377b2109a0f9c ./src/main.rs -fccf60732e66ff7d185ee8324196e9cea29a220a610a551b3b830799ef4cdbc4 ./src/models.rs +7de365e80b73a16b9429911fa683e0425830be7041fc889b594332de81d72cd5 ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs a60bd8d23b78c74447546722a03ff11c75367468d3bab9f2f5f9cb56f8e09572 ./src/protocol/gree.rs @@ -62,10 +62,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs 6081635986e1ce39a0e195c3976ae30a033454fe90ec80bcbdf05fc26bf7087f ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -23dcfdfc9eb2093c7289dad522dcaec76c802c7b45dbd58e074b3bdf8be2f323 ./web/app.js +0ffb96f6d494b24632c0825b76c21a88d2d9444e35208c854a01495c36369ec6 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg 5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest 59df58dc528fbbbb5b6097df7110e78d35951506cc056816c7b2be51f4d51965 ./web/styles.css -f7d2bae7f7abe896ad56996b5423547e4edd056c9bcd7fedbbf89ea91f5bacee ./web/sw.js +6206538cd2f546463387af542515137c3ae3e100ff0d4843a6af73a05705283b ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 527ede8..2608605 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.7.11**. +Current version: **0.7.12**. ## 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.7.11.zip +unzip gree-controller-v0.7.12.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -478,6 +478,14 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C +## 0.7.12 Timed quick-thermostat hand-back + +- Switching a single Quick Thermostat **OFF** now starts a 15-minute local-OFF window instead of leaving the zone outside automation indefinitely. +- The Web UI shows a live `MM:SS` countdown and a **Resume now** action during that window. +- At expiry, only local quick-thermostat ownership and its temporary quick profile/setpoint are cleared. The controller does not restore a stale physical ON/OFF state; it recalculates the current group, house mode, schedule, profile, room temperature and hysteresis. +- Switching the local thermostat back **ON** cancels the pending hand-back. A physical/remote `device_manual_override` remains separate and is not cleared by the local timer. +- Persisted local-OFF state from 0.7.10/0.7.11 receives a fresh 15-minute hand-back deadline on the first controller cycle after upgrade. + ## 0.7.11 Cleanup and Rust test fix - Removed the obsolete `/api/zones/{id}/manual-power` compatibility endpoint and its handler; local thermostat power has one canonical API path: `/api/zones/{id}/control`. diff --git a/docs/API.md b/docs/API.md index e5d043e..c202875 100644 --- a/docs/API.md +++ b/docs/API.md @@ -137,7 +137,7 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl `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; `false` keeps only that zone locally off. `clear_local_thermostat_override:true` returns power ownership to the normal group/schedule path. Direct/pilot-style control remains `/api/devices/{id}/command`. +`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. `false` turns only that zone off immediately and sets `local_thermostat_resume_at` to 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`. ### Physical/manual device takeover @@ -217,7 +217,7 @@ When cooling is driven by a valid per-zone Home Assistant room sensor (`external On process startup the thermostat/schedule/automation engine stays command-passive until one full live poll of all enabled GREE units completes. Persisted device/zone/group state is still available immediately for the UI, but it is not used to emit climate commands before the physical state has been synchronized. -`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode, uniform house preset (or `null` for mixed zone profiles), whole-house master-power state, strategy, each zone's configured `enabled` state, group-gated `effective_enabled` state, effective `mode`, `configured_mode`, `inherit_house_mode`, resolved `preset`, explicit `preset_override` (`null` = Auto schedule), current/target temperatures, physical `device_setpoint`, current schedule, manual schedule override expiry, local quick-thermostat power ownership, physical/manual device override state and expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. During physical/manual takeover, `target_temperature` remains the thermostat profile/schedule target while `device_setpoint` reports the actual GREE target; automation remains paused until resumed or another explicit zone control is sent. +`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode, uniform house preset (or `null` for mixed zone profiles), whole-house master-power state, strategy, each zone's configured `enabled` state, group-gated `effective_enabled` state, effective `mode`, `configured_mode`, `inherit_house_mode`, resolved `preset`, explicit `preset_override` (`null` = Auto schedule), current/target temperatures, physical `device_setpoint`, current schedule, manual schedule override expiry, local quick-thermostat power ownership and automatic resume deadline, physical/manual device override state and expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. During physical/manual takeover, `target_temperature` remains the thermostat profile/schedule target while `device_setpoint` reports the actual GREE target; automation remains paused until resumed or another explicit zone control is sent. ```bash curl "$BASE/api/control-plan" -H "$AUTH" diff --git a/home-assistant/README.md b/home-assistant/README.md index 14be07c..8c58ad2 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -122,7 +122,7 @@ The recommended `combined` strategy keeps the GREE sensor as the primary input a Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility. -From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone: local ON may run the zone while its group is off, with full thermostat logic intact, and local OFF keeps that zone off until normal automation ownership is resumed. The separate zone Enabled switch remains the technical availability switch. +From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone: local ON may run the zone while its group is off, with full thermostat logic intact. From 0.7.12, local OFF keeps that zone off for 15 minutes and then automatically returns ownership to the current group/schedule logic; the deadline is exposed as `local_thermostat_resume_at`. The separate zone Enabled switch remains the technical availability switch. From version 0.6.4, zone climate entities use `climate._thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning. diff --git a/home-assistant/custom_components/gree_controller/climate.py b/home-assistant/custom_components/gree_controller/climate.py index 796adff..3a02e48 100644 --- a/home-assistant/custom_components/gree_controller/climate.py +++ b/home-assistant/custom_components/gree_controller/climate.py @@ -330,6 +330,7 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl "controller_zone_id": self._zone_id, "effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))), "local_thermostat_power": zone.get("local_thermostat_power"), + "local_thermostat_resume_at": zone.get("local_thermostat_resume_at"), "effective_mode": zone.get("mode"), "follows_house_mode": bool(zone.get("inherit_house_mode", True)), "auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode", diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 40c1bdd..06055b0 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.7.11", + "version": "0.7.12", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index e291e6c..add1913 100644 --- a/lang/en.json +++ b/lang/en.json @@ -597,12 +597,15 @@ "zones.localThermostatOn": "Local control · on", "zones.localThermostatOff": "Local control · off", "zones.localThermostatOnDescription": "This zone runs locally regardless of the group power gate. Sensor, hysteresis, profile and GREE regulation remain active.", - "zones.localThermostatOffDescription": "This zone is locally off and its group cannot start it until automation is resumed.", + "zones.localThermostatOffDescription": "This zone is locally off. Without an active countdown it remains outside group control until automation is resumed manually.", "zones.unitPowerLocalHint": "Turns this zone's local thermostat on or off; this is not remote-style or direct device control.", "zones.localPowerUpdated": "Local thermostat state updated", "zones.turnThermostatOn": "Turn thermostat on", "zones.turnThermostatOff": "Turn thermostat off", "zones.waiting": "Waiting", - "zones.noMeasurement": "No measurement" + "zones.noMeasurement": "No measurement", + "zones.localThermostatOffDescriptionTimed": "This zone is locally off. Automation takes control again in {time}.", + "zones.localThermostatResuming": "Timer elapsed — automation is taking control…", + "zones.resumeNow": "Resume now" } } diff --git a/lang/pl.json b/lang/pl.json index 7428f2e..8501b03 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -597,12 +597,15 @@ "zones.localThermostatOn": "Sterowanie lokalne · włączone", "zones.localThermostatOff": "Sterowanie lokalne · wyłączone", "zones.localThermostatOnDescription": "Strefa działa lokalnie niezależnie od bramki grupy. Czujnik, histereza, profil i regulacja GREE pozostają aktywne.", - "zones.localThermostatOffDescription": "Strefa jest lokalnie wyłączona i grupa jej nie uruchomi do czasu wznowienia automatyki.", + "zones.localThermostatOffDescription": "Strefa jest lokalnie wyłączona. Bez aktywnego licznika pozostaje poza sterowaniem grupy do ręcznego wznowienia automatyki.", "zones.unitPowerLocalHint": "Włącza lub wyłącza lokalny termostat tej strefy; nie jest to sterowanie pilotem ani bezpośrednia komenda urządzenia.", "zones.localPowerUpdated": "Zmieniono stan lokalnego termostatu", "zones.turnThermostatOn": "Włącz termostat", "zones.turnThermostatOff": "Wyłącz termostat", "zones.waiting": "Oczekiwanie", - "zones.noMeasurement": "Brak pomiaru" + "zones.noMeasurement": "Brak pomiaru", + "zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Automatyka przejmie sterowanie za {time}.", + "zones.localThermostatResuming": "Czas minął — automatyka przejmuje sterowanie…", + "zones.resumeNow": "Wznów teraz" } } diff --git a/src/api.rs b/src/api.rs index f52141c..d7bf1b6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -529,7 +529,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, 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, + active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: 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, effective_mode: String::new(), effective_setpoint: None, device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, @@ -577,6 +577,7 @@ async fn update_zone(State(state): State, Path(id): Path, Json zone.manual_setpoint = existing.manual_setpoint; 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.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; @@ -618,16 +619,19 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl || patch.preset.is_some() || patch.enabled.is_some(); if resume_local_thermostat { - zone.local_thermostat_power = None; - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; + engine::reset_local_thermostat_override(&mut zone); } if let Some(power) = patch.power { zone.local_thermostat_power = Some(power); + zone.local_thermostat_resume_at = if power { + None + } else { + Some(Utc::now() + chrono::Duration::minutes(engine::LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) + }; if power { zone.enabled = true; } // A manually started local thermostat keeps an already selected target/profile until - // the user chooses Auto or resumes global automation. + // it is switched off and the delayed hand-back completes, Auto is selected, or the + // user explicitly resumes automation. if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) { zone.manual_override_until = None; } @@ -680,7 +684,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } if let Some(value) = patch.enabled { zone.enabled = value; - if !value { zone.local_thermostat_power = None; } + if !value { engine::reset_local_thermostat_override(&mut zone); } } let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; let house_mode = state.settings.read().await.house_mode.clone(); @@ -709,6 +713,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl "inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset, "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, "device_manual_override_cleared": device_override_cleared })); Ok(zone) @@ -955,6 +960,7 @@ async fn list_home_assistant_groups(State(state): State) -> Result>(); @@ -1009,8 +1015,8 @@ 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() { continue; } - zone.local_thermostat_power = None; + if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() { continue; } + engine::reset_local_thermostat_override(&mut zone); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); diff --git a/src/engine.rs b/src/engine.rs index 20ea1d4..6b6947e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -47,8 +47,17 @@ pub fn start(state: AppState) { tracing::error!(error=?err, "automation cycle failed"); } 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) { + Ok(value) => value, + Err(err) => { + tracing::warn!(error=?err, "cannot calculate local thermostat resume deadline"); + None + } + }; + let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay); tokio::select! { - _ = sleep(Duration::from_secs(seconds)) => {}, + _ = sleep(sleep_for) => {}, _ = control_state.zone_control_wakeup.notified() => {}, } } @@ -561,6 +570,62 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon fields } + +pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15; + +pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { + let changed = zone.local_thermostat_power.is_some() + || zone.local_thermostat_resume_at.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.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + changed +} + +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() { + if zone.local_thermostat_power == Some(false) && zone.local_thermostat_resume_at.is_none() { + // Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases + // had no hand-back deadline, so start one from the first cycle after upgrade. + zone.local_thermostat_resume_at = Some(now.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)); + zone.updated_at = now.clone(); + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES + })); + continue; + } + let expired = zone.local_thermostat_power == Some(false) + && zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false); + if !expired { continue; } + 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.local_thermostat_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES + })); + } + Ok(()) +} + +fn next_local_thermostat_resume_delay(state: &AppState) -> Result, AppError> { + let now = Utc::now(); + Ok(state.db.list_zones()?.into_iter() + .filter(|zone| zone.local_thermostat_power == Some(false)) + .filter_map(|zone| zone.local_thermostat_resume_at) + .map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO)) + .min()) +} + pub fn reset_device_manual_override(zone: &mut Zone) -> bool { let changed = zone.device_manual_override || zone.device_manual_override_since.is_some() @@ -961,7 +1026,11 @@ async fn control_zones(state: &AppState) -> Result<()> { let schedules = state.db.list_schedules()?; let groups = state.db.list_groups()?; let settings = state.settings.read().await.clone(); - let zone_snapshot = state.db.list_zones()?; + let mut zone_snapshot = state.db.list_zones()?; + // Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker + // 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)?; // Outdoor temperature is deliberately optional. Prefer the configured Home // Assistant entity, but keep the dashboard/assist useful by falling back to the @@ -1857,6 +1926,7 @@ pub async fn build_control_plan(state: &AppState) -> Result, + /// Automatic hand-back deadline after the local quick thermostat is switched OFF. + /// When reached, local ownership and quick profile/setpoint overrides are cleared and + /// the current group/schedule state is evaluated again. + #[serde(default)] + pub local_thermostat_resume_at: 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)] @@ -738,6 +743,7 @@ pub struct ZoneControlPlan { pub control_source: String, pub manual_override_until: Option>, pub local_thermostat_power: Option, + pub local_thermostat_resume_at: Option>, pub device_manual_override: bool, pub device_manual_override_until: Option>, pub current_schedule_id: Option, diff --git a/web/app.js b/web/app.js index 4bcd755..9c0ddba 100644 --- a/web/app.js +++ b/web/app.js @@ -47,6 +47,25 @@ const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mod const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode); const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value); const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—'; +const localResumeSeconds = value => { + const timestamp = value ? new Date(value).getTime() : NaN; + return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null; +}; +const formatCountdown = seconds => { + if (!Number.isFinite(seconds)) return '--:--'; + const safe = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safe / 60); + const remainder = safe % 60; + return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`; +}; +function updateLocalThermostatCountdowns() { + $$('[data-local-resume-countdown]').forEach(node => { + const remaining = localResumeSeconds(node.dataset.localResumeCountdown); + node.textContent = remaining !== null && remaining > 0 + ? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)}) + : tr('zones.localThermostatResuming'); + }); +} const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity; function updateConnectionIndicator(status) { @@ -713,6 +732,7 @@ function zoneCard(zone, detailed = true) { const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null; const deviceManualOverride = zone.device_manual_override === true; const localThermostatPower = zone.local_thermostat_power; + const localThermostatResumeAt = zone.local_thermostat_resume_at || null; const localThermostatOverride = localThermostatPower === true || localThermostatPower === false; const override = deviceManualOverride ? (zone.device_manual_override_until @@ -726,7 +746,9 @@ function zoneCard(zone, detailed = true) { const manualTakeover = deviceManualOverride ? `
${esc(tr('zones.manualDeviceControl'))}

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

` : localThermostatOverride - ? `
${esc(tr('zones.localThermostatControl'))}

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

` + ? `
${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); @@ -2223,4 +2245,5 @@ async function startApplication() { if (location.pathname === '/' || location.pathname === `${APP_BASE}/`) updateBrowserUrl('/dashboard', true); } +setInterval(updateLocalThermostatCountdowns, 1000); startApplication().catch(error => console.error('Application startup failed:', error)); diff --git a/web/sw.js b/web/sw.js index 40c7858..2e554a6 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'gree-controller-v0711-clean-local-thermostat'; +const CACHE = 'gree-controller-v0712-timed-local-resume'; 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')];