From 9fa0a5399e8f91164ad6c1f6e7524c0557fb1567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 25 Aug 2026 22:14:14 +0200 Subject: [PATCH] v0.7.4 --- BUILD_REPORT.md | 45 ++- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 30 +- README.md | 16 +- docs/API.md | 19 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 11 +- lang/pl.json | 11 +- src/api.rs | 79 ++++- src/engine.rs | 333 ++++++++++++++++-- src/models.rs | 16 + web/app.js | 51 ++- web/index.html | 2 +- web/styles.css | 3 + web/sw.js | 2 +- 16 files changed, 540 insertions(+), 84 deletions(-) diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index ec09937..7dc1496 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,4 +1,4 @@ -# GREE Controller v0.7.3 - build and validation report +# GREE Controller v0.7.4 - build and validation report ## Scope @@ -282,3 +282,46 @@ Validation for the 0.7.1 package: - `bash -n scripts/*.sh` passed. - HTML parsing and CSS brace-balance checks passed. - The validation environment does not provide `cargo`/`rustc`, so the Rust test/build step must run on the target host/LXC (the normal `scripts/update.sh` performs `cargo test --all-targets` before installation). + +## 2026-08-25 disabled-zone and standby-command hotfix + +- Disabled thermostat zones are excluded from normal house, group-loop and direct-device automation ownership while sensor polling/history continue normally. +- Disabling an active zone now sends one immediate power-off command to its assigned unit. Later manual operation remains possible through the technical Devices control path. +- Whole-house ON only powers devices assigned to enabled thermostat zones; unassigned devices and devices belonging to disabled zones are not started. +- Whole-house OFF now cascades group power gates to OFF and powers off all enabled physical devices. Whole-house ON restores group gates but still respects disabled zones. +- Restricted Home Assistant direct-device commands are rejected for devices assigned to disabled zones; zone/group/house endpoints keep their managed semantics. +- Dashboard direct-device controls are locked for devices assigned to disabled zones and point the operator to the Devices view for explicit technical/manual control. +- Smart Quiet now follows demand transitions instead of being reasserted throughout steady standby. Outside the night window it no longer fights Smart Fan by toggling Quiet off/on on successive cycles. +- Standby Low fan is sent on a relevant transition/core thermostat change rather than retried forever when firmware reports a different fan state, preventing periodic command/beep loops with unchanged room temperature and setpoint. +- Added regression assertions for steady-standby Smart Quiet behavior and night-mode/Smart-Fan interaction. +- PWA cache key changed so browsers fetch the corrected frontend and language strings after deployment. + +Validation in this packaging environment: + +- `node --check web/app.js` and `node --check web/sw.js` passed. +- PL/EN application language JSON parsed successfully and translation-key parity passed. +- `web/index.html` parsed successfully with Python's HTML parser. +- Home Assistant Python modules and JSON manifests/translations passed syntax/parse checks. +- Shell scripts passed `bash -n`. +- Rust source was statically reviewed and delimiter-checked. This environment does not provide `cargo`, `rustc` or `rustfmt`; the normal target update path must run `cargo test --all-targets` and `cargo build --release` before installation. + + +## 0.7.4 physical/manual takeover + +- Added persistent per-zone device manual override state with source fields, start time and optional next-schedule expiry. +- Polling detects external power/mode/setpoint/fan changes relative to the last controller-known state and suspends automatic correction for that zone. +- Known standby Low-to-Auto fan normalization is ignored to avoid false remote-control detection. +- Technical climate commands from the Devices endpoint enter the same takeover; light-only/non-thermostat feature changes do not. +- Manual takeover has priority over zone modulation, climate groups and direct-device automations. Whole-house power-off is the explicit exception and clears all takeovers. +- Added Web UI Manual control / remote state and Resume automation action, plus control-plan fields for integrations/simulation. +- Added regression tests for external climate-state detection, Low-to-Auto suppression and takeover reset state. + +### 0.7.4 validation + +- `node --check` passed for `web/app.js` and `web/sw.js`. +- PL/EN language packs parse successfully, have identical key sets (586 keys each), and all UI translation references resolve. +- Home Assistant Python modules and helper Python scripts pass `py_compile`; shell scripts pass `bash -n`. +- Cargo metadata parses from `Cargo.toml`; package, Cargo lock root package and Home Assistant manifest report `0.7.4`. +- `web/index.html` parses successfully and CSS braces are balanced. +- Rust source delimiter/static invariant checks pass, including poll detection, group/automation guards, whole-house override clear and technical manual-command routing. +- The packaging environment has no `cargo`, `rustc` or `rustfmt`; the target update path remains the compile gate and must run `cargo test --all-targets` and `cargo build --release` before replacing the running binary. diff --git a/Cargo.lock b/Cargo.lock index c16fd2e..a3bccfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 320ed8e..b617f4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.7.3" +version = "0.7.4" 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 b429e88..7b3bf71 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -f6c797abd321abcf1ad9ab454e2a2949d588ed1465b12fd3383530c71bda9070 ./BUILD_REPORT.md -cff434b43ec947285275a72653bde756b57987d7191ec4093f9218bd16653936 ./Cargo.lock -f3e0db0deb28cda7669b9344436d883529143f64e8dc89624986fe14b9fa502d ./Cargo.toml +49062c81883c8208913641da1d3ba15a006e1a9fdcbac126f75bbc9622d7deca ./BUILD_REPORT.md +179774a6d5ebb4d9ea5b9713f66658b5488d142132b4cffd44fa39bf79072bbc ./Cargo.lock +9d24427676b6d8dec334ed5ebc3bbc2318d2203f1edebbeecaf83bee4e882203 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -b526a6b28e1b8cb68be8bd92627a8588c0964de109f99b83c173ecec151ae543 ./README.md +f30bfd3fc5ca95db2902b984ad82a95a73595e2b91e94db1a645cee9d17526d1 ./README.md f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs -b396c7016569a5817ed740abadd0e5685a8e7d50300bfce607cc3c15009ba78d ./docs/API.md +eb343972b711426ca1e81684ffdc5f869db003b13afb6fd3828b954f8df7d964 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md @@ -23,7 +23,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./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 -61cb322659bf9cdc2490b20ad3c5aab92def81660d6a01dbcd978ddebc162813 ./home-assistant/custom_components/gree_controller/manifest.json +70fdf131ad73f4dd4cbecdb70a2014d6fae02d7cd8f31d0ea5367e4f7a887c7f ./home-assistant/custom_components/gree_controller/manifest.json d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./home-assistant/custom_components/gree_controller/select.py 047f3db03f0db54a40d63092370e5861510d611edfb0a0659e4c25b949d47854 ./home-assistant/custom_components/gree_controller/sensor.py @@ -32,8 +32,8 @@ a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./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 -cad2eb081cf7fbed275507763aafe288f25ae527c6775c84b89c1ac095d6843b ./lang/en.json -694c88c40860f7b14bcc6bd0575b6a53062302a4f1c29b7cb5829c00028dfa03 ./lang/pl.json +e11f49c05499b7dafac87cab2fe40022f43dc88df7dec6831c2c4ca8e4235cc0 ./lang/en.json +af59c7fdd4a48a59305cba6518291238b16089c3876b837ca7b8db57d691c507 ./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 -7b4309a30eed56af51c175db00e12649921edc6f515df477c68bc09c750929c9 ./src/api.rs +c2d15048e637e5582fdf2b8efafec3b8e212592d117a38dcee3f4f9354742669 ./src/api.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -0f818e27983476ad41bf8f647749bc5a7dc7924731bb436ec996b0c8e595f93d ./src/engine.rs +32711788456b9e432b6954069706b8c313fe886d1d1b648e235e780e2082fed6 ./src/engine.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs 081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs 31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs -13fb67f279f97410f9632401a887547c7b5c7f43c40f3220253bfa3e86b600a4 ./src/models.rs +9e53273156b0bcc510726ff6bc5923d8c845873c8638f4fe2f27553607e3cd5b ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs 0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs @@ -62,10 +62,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -27c39b66faab98b71ea515715c3744105f26bc274587aa4e6d595e16f3674f22 ./web/app.js +a8d6df8b217321c94441f1a3d4a14a71f4dbce874e8d3e9b7e1e2d323551ffb3 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -57949bed2229ceb180ea681b0126b56f6e75dc325d7a89d384f823282736bdc7 ./web/index.html +5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -e4ed0381250352f752e3b36133a89947db1adb08dec9cae7d2467684dbd1a807 ./web/styles.css -4a60c17e07f1c17d79d5316cb4a34418087f7428cc0326cf536c043261e926a4 ./web/sw.js +b3448404b5f7ff6970af5d9812213cdd8a458086e9405e0308001e2101afb44d ./web/styles.css +de6d0a8feda7f25a3fa45ccd32e41fd70a786f20f328640575c457d6e298ffd5 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 0d1bd2f..5cae58f 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.3**. +Current version: **0.7.4**. ## 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.3.zip +unzip gree-controller-v0.7.4.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -222,7 +222,7 @@ Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep / Aw ## Smart thermostat and schedules -Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. **House Off** is now a no-control state for inherited zones, so manual device operation is not overwritten; whole-house master power on/off is available separately and can authoritatively keep every unit off without changing the selected thermostat mode. +Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. Smart Quiet and standby Low fan are transition hints rather than commands that are blindly reasserted every adjustment interval, avoiding unnecessary beeps when the room and device setpoint are already stable. **House Off** is now a no-control state for inherited zones, so manual device operation is not overwritten; whole-house master power on/off is available separately and can authoritatively keep every unit off without changing the selected thermostat mode. Disabled zones are excluded from normal house/group/automation control; their physical unit remains available for explicit technical control from the Devices view. Choose one seasonal **global mode** — Cooling in summer, Heating in winter, or Off. Zones inherit this global mode by default, while a zone may explicitly override Heat/Cool when required. In **Mode policy**, leave **Follow global mode** unchanged when the zone should inherit the mode selected globally in GREE Controller; **Cooling only** / **Heating only** are deliberate per-zone overrides. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating. @@ -446,3 +446,13 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C - Mixed member modes/profiles are reported without inventing a false common value; choosing a group option normalizes all member zones through the existing group-control engine. - Group commands use the restricted Home Assistant access token and the dedicated `/api/integrations/home-assistant/groups/*` endpoints. - After adding/removing/renaming groups, reload the GREE Controller integration in Home Assistant so its entity/device registry is rebuilt. + +## 0.7.4 manual remote override + +- Physical GREE changes detected during polling for power, HVAC mode, target temperature and meaningful fan-speed changes now enter a per-zone **Manual control / remote** takeover state. +- While takeover is active, thermostat modulation, schedules, groups and direct-device automations observe the zone but do not overwrite the unit. +- Takeover expires at the next schedule transition when available; otherwise it remains until **Resume automation** or another explicit quick-thermostat action. +- Technical climate control from the Devices view uses the same takeover semantics, while unrelated controls such as panel light do not pause the thermostat. +- Disabled zones remain outside normal group/automation ownership; their unit can be operated manually. +- **Turn all off** remains authoritative: it clears all takeovers, disables groups and powers off enabled units. Whole-house/group ON paths do not wake zones currently protected by manual takeover. +- The UI and control-plan API expose the takeover state and its optional expiry. diff --git a/docs/API.md b/docs/API.md index 52536e3..633d39f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -81,7 +81,7 @@ curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/j -d '{"preset":"sleep"}' ``` -House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `POST /api/house/power` controls a separate whole-house master power state. With master power off, zones and controller automations cannot restart units; the selected house thermostat mode is preserved. This is independent from house mode `off`, which only means no house-level thermostat control and allows direct/manual device operation. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on and all enabled units are powered on. Choosing house mode `off` does not change master power. +House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `POST /api/house/power` controls a separate whole-house master power state. With master power off, zones and controller automations cannot restart units; the selected house thermostat mode is preserved. This is independent from house mode `off`, which only means no house-level thermostat control and allows direct/manual device operation. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on, all group power gates are enabled and only devices assigned to enabled thermostat zones that are not in manual-device override are powered on. `power:false` is authoritative: it clears manual-device overrides, disables every group and powers off every enabled physical device. Disabled zones otherwise remain outside house/group/automation control and their underlying unit can be operated manually through technical device control or its physical remote. Choosing house mode `off` does not change master power. A zone stores separate profile temperatures for both seasons: @@ -128,6 +128,19 @@ 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. +### 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. The zone continues sensor/history updates but thermostat modulation, schedules, groups and direct-device automations stop issuing corrective climate commands. The 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. + +The technical `POST /api/devices/{id}/command` path uses the same takeover semantics for climate-relevant changes; light-only and unrelated feature changes do not suspend the thermostat. An explicit quick-zone action (target, mode, preset, enabled state) resumes thermostat ownership automatically. It can also be resumed directly: + +```bash +curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"clear_device_manual_override":true}' +``` + +Whole-house `power:false` is the deliberate exception and always clears the takeover and powers the unit off. Group power/mode/profile commands do not fight a zone while its manual-device override is active. + ### Climate groups Groups persist a set of thermostat zone IDs and an independent group power gate. Group control reuses the normal zone engine, so profiles, schedules, hysteresis and sensor selection remain unchanged. @@ -189,7 +202,7 @@ This setting is opt-in and applies only to the controller's outbound Home Assist ## Current control plan -`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 effective `mode`, `configured_mode`, `inherit_house_mode`, preset/current and target temperatures, current schedule, manual override expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. +`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 effective `mode`, `configured_mode`, `inherit_house_mode`, preset/current and target temperatures, current schedule, manual schedule override expiry, physical/manual device override state and expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. ```bash curl "$BASE/api/control-plan" -H "$AUTH" @@ -197,6 +210,8 @@ curl "$BASE/api/control-plan" -H "$AUTH" The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also read derived group state from `GET /api/integrations/home-assistant/groups`, control a group through `POST /api/integrations/home-assistant/groups/{id}/control`, change house mode/preset/master power through `/api/integrations/home-assistant/house/*`, and change a zone target/preset/mode/enabled state through: +Direct HA device commands are rejected while the device belongs to a disabled thermostat zone. Re-enable the zone for normal controller/HA ownership; the unrestricted `/api/devices/{id}/command` endpoint remains the technical/manual path used by the Devices view. Climate-relevant direct device commands on an enabled zone create the same manual-device override as a physical remote change, preventing the thermostat engine from immediately undoing them. + The group list reports the configured group power state, common mode/profile (or `mixed`), member zones, online device count, average room temperature, demand count and upcoming member schedule events. Group control accepts the same semantics as the Web UI: ```bash diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 1ce82d7..5ca9dc8 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.3", + "version": "0.7.4", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index a3d6419..8169f5c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -73,6 +73,7 @@ "devices.readDone": "Device state updated", "devices.bound": "Device bound", "devices.added": "Device added", + "devices.disabledZoneTechnicalOnly": "This zone is disabled. Manual control of its unit is available only from the Devices view.", "zones.automation": "Automation", "zones.new": "New zone", "zones.description": "Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature, preset and mode changes.", @@ -538,10 +539,10 @@ "house.noControl": "Do not control", "house.powerAllEyebrow": "Global power", "house.powerAllTitle": "All air conditioners", - "house.powerAllHint": "Master power for all enabled units. “Turn all off” prevents them from restarting. Choosing whole-house Cooling/Heating or an Auto/Comfort/Sleep/Away profile automatically switches master power back on.", + "house.powerAllHint": "Master power for thermostat zones. “Turn all off” also turns off every group and unit. “Turn all on” starts enabled zones only; disabled zones remain disabled. Choosing whole-house Cooling/Heating or a profile automatically enables master power.", "house.powerOnAll": "Turn all on", "house.powerOffAll": "Turn all off", - "house.powerOnDone": "All air conditioners turned on", + "house.powerOnDone": "All enabled zones turned on", "house.powerOffDone": "All air conditioners turned off", "house.powerPartial": "Could not change power on {count} devices", "preset.manual": "Manual", @@ -586,6 +587,10 @@ "zones.controlNow": "Control", "zones.noGroup": "no group", "zones.coolOnly": "Cooling only", - "zones.heatOnly": "Heating only" + "zones.heatOnly": "Heating only", + "zones.manualDeviceControl": "Manual control / remote", + "zones.manualDeviceUntil": "Manual control / remote until {time}", + "zones.manualDeviceNoBoundary": "Manual control / remote · until resumed", + "zones.resumeAutomation": "Resume automation" } } diff --git a/lang/pl.json b/lang/pl.json index 3b24329..91e3a6a 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -73,6 +73,7 @@ "devices.readDone": "Odczyt zakończony", "devices.bound": "Urządzenie powiązane", "devices.added": "Urządzenie dodane", + "devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Ręczne sterowanie tą jednostką jest dostępne tylko w zakładce Urządzenia.", "zones.automation": "Automatyka", "zones.new": "Nowa strefa", "zones.description": "Strefy definiują logikę termostatu i konfigurację czujników. Bieżącą temperaturę, profil i tryb zmieniaj w „Szybkich termostatach” na Pulpicie.", @@ -538,10 +539,10 @@ "house.noControl": "Nie steruj", "house.powerAllEyebrow": "Zasilanie globalne", "house.powerAllTitle": "Wszystkie klimatyzatory", - "house.powerAllHint": "Nadrzędne zasilanie wszystkich aktywnych jednostek. „Wyłącz wszystkie” blokuje ich ponowne uruchamianie. Globalny wybór Chłodzenia/Grzania albo profilu Auto/Komfort/Sen/Poza domem automatycznie ustawia „Włącz wszystkie”.", + "house.powerAllHint": "Nadrzędne zasilanie stref. „Wyłącz wszystkie” wyłącza też wszystkie grupy i jednostki. „Włącz wszystkie” uruchamia tylko włączone strefy — strefy wyłączone pozostają wyłączone. Globalny wybór Chłodzenia/Grzania albo profilu automatycznie włącza zasilanie globalne.", "house.powerOnAll": "Włącz wszystkie", "house.powerOffAll": "Wyłącz wszystkie", - "house.powerOnDone": "Włączono wszystkie klimatyzatory", + "house.powerOnDone": "Włączono wszystkie aktywne strefy", "house.powerOffDone": "Wyłączono wszystkie klimatyzatory", "house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń", "preset.manual": "Ręcznie", @@ -586,6 +587,10 @@ "zones.controlNow": "Steruj", "zones.noGroup": "bez grupy", "zones.coolOnly": "Tylko chłodzenie", - "zones.heatOnly": "Tylko grzanie" + "zones.heatOnly": "Tylko grzanie", + "zones.manualDeviceControl": "Sterowanie ręczne / pilot", + "zones.manualDeviceUntil": "Sterowanie ręczne / pilot do {time}", + "zones.manualDeviceNoBoundary": "Sterowanie ręczne / pilot · do wznowienia", + "zones.resumeAutomation": "Wznów automatykę" } } diff --git a/src/api.rs b/src/api.rs index fb96d6c..09b8661 100644 --- a/src/api.rs +++ b/src/api.rs @@ -78,7 +78,7 @@ pub fn router(state: AppState) -> Router { let home_assistant_api = Router::new() .route("/api/integrations/home-assistant/devices", get(list_devices)) - .route("/api/integrations/home-assistant/devices/:id/command", post(command_device)) + .route("/api/integrations/home-assistant/devices/:id/command", post(command_home_assistant_device)) .route("/api/integrations/home-assistant/control-plan", get(control_plan)) .route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups)) .route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control)) @@ -413,7 +413,14 @@ async fn poll_device(State(state): State, Path(id): Path) -> R } async fn command_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { - Ok(Json(engine::send_command(&state, &id, command).await?)) + Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?)) +} + +async fn command_home_assistant_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { + if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) { + return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into())); + } + Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?)) } #[derive(Debug, Deserialize)] @@ -509,6 +516,7 @@ impl ZoneInput { 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, + device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), effective_mode: String::new(), effective_setpoint: None, device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at, updated_at: Utc::now(), @@ -552,6 +560,10 @@ async fn update_zone(State(state): State, Path(id): Path, Json zone.manual_preset = existing.manual_preset; zone.manual_setpoint = existing.manual_setpoint; zone.manual_override_until = existing.manual_override_until; + 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; + zone.device_manual_override_fields = existing.device_manual_override_fields; zone.effective_mode = existing.effective_mode; zone.effective_setpoint = existing.effective_setpoint; zone.device_setpoint = existing.device_setpoint; @@ -561,13 +573,22 @@ async fn update_zone(State(state): State, Path(id): Path, Json zone.last_action_at = existing.last_action_at; let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); + let power_off_device = existing.enabled && !zone.enabled; state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); + if power_off_device { + power_off_zone_device(&state, &zone, "zone.disabled").await; + } Ok(Json(zone)) } async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let was_enabled = zone.enabled; let schedules = state.db.list_schedules()?; + // Any explicit thermostat action means the user is handing ownership back to the zone + // controller. This includes the dedicated Resume automation button. + let resume_device_automation = patch.clear_device_manual_override.unwrap_or(false) + || patch.setpoint.is_some() || patch.mode.is_some() || patch.preset.is_some() || patch.enabled.is_some(); if let Some(value) = patch.setpoint { if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); } @@ -608,17 +629,35 @@ async fn update_zone_control(State(state): State, Path(id): Path) -> Result>(); output.push(json!({ @@ -863,10 +904,10 @@ async fn update_home_assistant_group_control( #[derive(Debug, Deserialize)] struct HouseControlPatch { mode: String } -fn enable_all_groups(state: &AppState) -> Result<(), AppError> { +fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { for mut group in state.db.list_groups()? { - if group.power_enabled { continue; } - group.power_enabled = true; + if group.power_enabled == power { continue; } + group.power_enabled = power; group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); @@ -876,8 +917,19 @@ fn enable_all_groups(state: &AppState) -> Result<(), AppError> { async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result, AppError> { let mut failed = Vec::new(); + let enabled_zone_devices: std::collections::HashSet = if power { + state.db.list_zones()?.into_iter() + .filter(|zone| zone.enabled && !zone.device_manual_override) + .map(|zone| zone.device_id) + .collect() + } else { + std::collections::HashSet::new() + }; for device in state.db.list_devices()? { if !device.enabled || device.power == power { continue; } + // Whole-house ON only operates thermostat-managed, enabled zones. Devices with + // a disabled zone (or no zone at all) remain manual/technical Devices controls. + if power && !enabled_zone_devices.contains(&device.id) { continue; } let command = DeviceCommand { power: Some(power), ..Default::default() }; if let Err(err) = engine::send_command(state, &device.id, command).await { state.log("error", "house.power_all_error", &err.to_string(), json!({ @@ -914,7 +966,7 @@ async fn update_house_control(State(state): State, Json(input): Json, Json(input): Json) -> Result, AppError> { // Whole-house power is independent from the thermostat mode. Turning it off is // authoritative, while house mode `off` remains a separate "do not control" state. + if !input.power { + engine::clear_all_device_manual_overrides(&state, "house_power_off")?; + } { let mut settings = state.settings.write().await; if settings.house_power_enabled != input.power { @@ -943,20 +998,24 @@ async fn update_house_power(State(state): State, Json(input): Json, Json(input): Json Result { let mut device = state.db.get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + let before = device.clone(); poll_device(state, &mut device).await; + let concurrent_command = state.db.get_device(device_id)? + .map(|current| current.updated_at > before.updated_at) + .unwrap_or(false); + if !concurrent_command && poll_completed_successfully(&device) { + detect_external_device_control(state, &before, &device)?; + } state.db.save_device(&device)?; record_reading(state, &device)?; state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); @@ -188,7 +195,14 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result Result<()> { for mut device in state.db.list_devices()? { if !device.enabled { continue; } + let before = device.clone(); poll_device(state, &mut device).await; + let concurrent_command = state.db.get_device(&device.id)? + .map(|current| current.updated_at > before.updated_at) + .unwrap_or(false); + if !concurrent_command && poll_completed_successfully(&device) { + detect_external_device_control(state, &before, &device)?; + } state.db.save_device(&device)?; record_reading(state, &device)?; state.broadcast("device.updated", serde_json::to_value(&device)?); @@ -331,6 +345,138 @@ pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> Ok(()) } +fn poll_completed_successfully(device: &Device) -> bool { + device.online && device.communication_failures == 0 && device.last_error.is_none() +} + +fn command_manual_control_fields(command: &DeviceCommand) -> Vec { + let mut fields = Vec::new(); + if command.power.is_some() { fields.push("power".to_string()); } + if command.mode.is_some() { fields.push("mode".to_string()); } + if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); } + if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); } + if command.quiet.is_some() { fields.push("quiet".to_string()); } + if command.sleep.is_some() { fields.push("sleep".to_string()); } + fields +} + +fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec { + let mut fields = Vec::new(); + if before.power != after.power { fields.push("power".to_string()); } + if before.mode != after.mode { fields.push("mode".to_string()); } + if (before.target_temperature - after.target_temperature).abs() >= 0.5 { + fields.push("target_temperature".to_string()); + } + // Some GREE units accept the controller's standby Low fan hint and later report Auto + // again without user interaction. Treat that one known normalization as firmware drift, + // not as a remote-control takeover. Other fan changes remain meaningful manual input. + let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0; + if before.fan_speed != after.fan_speed && !standby_low_to_auto { + fields.push("fan_speed".to_string()); + } + fields +} + +pub fn reset_device_manual_override(zone: &mut Zone) -> bool { + let changed = zone.device_manual_override + || zone.device_manual_override_since.is_some() + || zone.device_manual_override_until.is_some() + || !zone.device_manual_override_fields.is_empty(); + zone.device_manual_override = false; + zone.device_manual_override_since = None; + zone.device_manual_override_until = None; + zone.device_manual_override_fields.clear(); + changed +} + +fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec, source: &str) -> Result<(), AppError> { + if fields.is_empty() { return Ok(()); } + let now = Utc::now(); + if !zone.device_manual_override { + zone.device_manual_override_since = Some(now); + } + zone.device_manual_override = true; + zone.device_manual_override_until = if zone.enabled { + next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()) + } else { + None + }; + zone.device_manual_override_fields = fields.clone(); + zone.demand = false; + zone.demand_since = None; + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({ + "zone_id": zone.id, + "device_id": zone.device_id, + "fields": fields, + "source": source, + "override_until": zone.device_manual_override_until, + })); + Ok(()) +} + +fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { + if before.id != after.id { return Ok(()); } + for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { + let fields = externally_changed_control_fields(before, after, &zone); + if fields.is_empty() { continue; } + // A disabled zone is outside controller ownership. When its manually operated unit is + // switched off there is no takeover left to display or remember. + if !zone.enabled && !after.power { + if reset_device_manual_override(&mut zone) { + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.log("info", "zone.device_manual_override_cleared", &format!("Manual device control ended for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "source": "gree_poll" + })); + } + continue; + } + set_device_manual_override(state, &mut zone, fields, "gree_poll")?; + } + Ok(()) +} + +pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result { + let before = state.db.get_device(device_id)? + .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + let effective_command = if before.online { command.changed_from(&before) } else { command.clone() }; + let fields = command_manual_control_fields(&effective_command); + let updated = send_command(state, device_id, command).await?; + if !fields.is_empty() { + for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { + if !zone.enabled && !updated.power { + if reset_device_manual_override(&mut zone) { + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + continue; + } + set_device_manual_override(state, &mut zone, fields.clone(), source)?; + } + } + Ok(updated) +} + +pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result { + let mut cleared = 0usize; + for mut zone in state.db.list_zones()? { + if !reset_device_manual_override(&mut zone) { continue; } + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "source": source + })); + cleared += 1; + } + Ok(cleared) +} + pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result { if let Some(mode) = patch.mode.as_deref() { if !matches!(mode, "house" | "auto" | "cool" | "heat") { @@ -398,10 +544,12 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl let mut seen = std::collections::HashSet::new(); for zone in &zones { if !seen.insert(zone.device_id.clone()) { continue; } + // Group actions never own disabled zones and never override an active manual/pilot + // takeover. Whole-house OFF is handled separately and remains authoritative. + if !zone.enabled || zone.device_manual_override { continue; } let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; if !device.enabled || device.power == desired_power { continue; } if desired_power { - if !zone.enabled { continue; } let blocked_by_other_group = group_snapshot.iter().any(|other| { other.id != group.id && !other.power_enabled && other.zone_ids.iter().any(|zone_id| zone_id == &zone.id) }); @@ -480,6 +628,9 @@ async fn control_zones(state: &AppState) -> Result<()> { let night_active = night_mode_active(&settings.night_mode, Local::now().time()); if !settings.house_power_enabled { + // Whole-house OFF is the one deliberate authority above manual/pilot takeover. + // Clear remembered takeovers as well, so a later whole-house ON starts cleanly. + clear_all_device_manual_overrides(state, "house_master_off")?; for device in &device_snapshot { if !device.enabled || !device.power { continue; } if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await { @@ -495,6 +646,12 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.manual_setpoint = None; zone.manual_override_until = None; } + if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { + reset_device_manual_override(&mut zone); + state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id + })); + } let Some(device) = state.db.get_device(&zone.device_id)? else { state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id})); @@ -543,6 +700,35 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.control_temperature_source = control_source; zone.updated_at = Utc::now(); + // 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 { + zone.demand = false; + zone.demand_since = None; + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + continue; + } + + // A physical/manual takeover has higher priority than thermostat, schedule, group and + // automation control. Continue sensor/history updates, but reflect the unit's real state + // instead of sending corrective frames that would fight the person holding the remote. + if zone.device_manual_override { + zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; + zone.effective_setpoint = if device.power { Some(device.target_temperature) } else { None }; + zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; + zone.demand = false; + zone.demand_since = None; + zone.target_alerted_at = None; + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + continue; + } + let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); if blocked_by_group { zone.effective_mode = "off".into(); @@ -570,17 +756,6 @@ async fn control_zones(state: &AppState) -> Result<()> { })); } - // Disabled zones still refresh and publish their room temperature. Disabling a - // thermostat stops control actions, but it must not make the room sensor disappear - // from the dashboard or zone view. - if !zone.enabled { - zone.demand = false; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - continue; - } - // House "off" is a no-control state, not a power-off command. Keep polling and // publishing the zone, but never overwrite manual device state while it follows // the house mode. Explicit per-zone heat/cool bypasses this branch above. @@ -655,6 +830,7 @@ async fn control_zones(state: &AppState) -> Result<()> { let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target }); zone.device_setpoint = Some(desired_device_target); + let demand_changed = previous_demand != zone.demand; let desired_fan = if night_active { let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5); if zone.smart_fan { @@ -694,10 +870,18 @@ async fn control_zones(state: &AppState) -> Result<()> { device.sleep, ); - let needs_command = !device.power + let core_needs_command = !device.power || device.mode != effective_mode - || (device.target_temperature - desired_device_target).abs() >= 0.5 - || desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false) + || (device.target_temperature - desired_device_target).abs() >= 0.5; + // In normal standby, Low fan is a transition hint rather than a state that should + // be reasserted forever. Some GREE firmwares accept the frame but later report Auto + // again; retrying every min_adjust_seconds only causes needless command beeps. + let fan_needs_command = desired_fan + .map(|fan| fan != device.fan_speed) + .unwrap_or(false) + && (zone.demand || demand_changed || core_needs_command || night_active); + let needs_command = core_needs_command + || fan_needs_command || desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false) || desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false); @@ -707,7 +891,7 @@ async fn control_zones(state: &AppState) -> Result<()> { power: Some(true), mode: Some(effective_mode.to_string()), target_temperature: Some(desired_device_target), - fan_speed: desired_fan, + fan_speed: if fan_needs_command { desired_fan } else { None }, quiet: desired_quiet, sleep: desired_sleep, ..Default::default() @@ -914,13 +1098,20 @@ fn smart_quiet_command( night_force_quiet: bool, ) -> Option { if !quiet_supported { return None; } - if night_enabled && night_force_quiet { - if night_active { return Some(true); } - if device_quiet { return Some(false); } + if night_enabled && night_force_quiet && night_active { + return if device_quiet { None } else { Some(true) }; } - if !smart_fan { return None; } - if !demand { return Some(true); } - if !previous_demand && device_quiet { return Some(false); } + if smart_fan { + // Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a + // satisfied room remains in standby: some units report Quiet=false again even after + // accepting the command, which otherwise produces a beep every adjustment interval. + if previous_demand && !demand && !device_quiet { return Some(true); } + if !previous_demand && demand && device_quiet { return Some(false); } + return None; + } + // Without Smart Fan, Quiet can only have been requested by scheduled night mode, + // so release it after the night window ends. + if night_enabled && night_force_quiet && device_quiet { return Some(false); } None } @@ -1120,7 +1311,14 @@ pub async fn build_control_plan(state: &AppState) -> Result Result Result<()> { _ => false, }; if !should_fire { continue; } + if item.action_group_id.is_none() && device_blocked_by_disabled_zone(&item.action_device_id, &zones) { + // Disabled thermostat zones are outside normal automation. The underlying + // unit can still be operated manually from the technical Devices view. + item.last_fired_at = Some(Utc::now()); + item.updated_at = Utc::now(); + state.db.save_automation(&item)?; + state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + continue; + } + if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) { + item.last_fired_at = Some(Utc::now()); + item.updated_at = Utc::now(); + state.db.save_automation(&item)?; + state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + continue; + } if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) { - // Group power-off is authoritative. Suppress a raw-device automation instead of - // waking the unit for one control cycle and immediately switching it off again. + // Group power-off is authoritative for normal controller-owned zones. A manual + // takeover is filtered above and therefore remains higher priority than the group. item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; @@ -1367,6 +1589,14 @@ async fn run_automations(state: &AppState) -> Result<()> { Ok(()) } +fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool { + zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled) +} + +fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool { + zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override) +} + fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool { let zone_ids: std::collections::HashSet<&str> = zones.iter() @@ -1508,11 +1738,53 @@ mod tests { sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), external_sensor_weight: 0.4, max_sensor_difference: 3.0, 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, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None, + manual_preset: None, manual_setpoint: None, manual_override_until: None, + device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), + effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(), } } + #[test] + fn external_device_change_detects_manual_climate_controls() { + let zone = test_zone("device"); + let before = Device::simulated_default(); + let mut after = before.clone(); + after.power = !before.power; + after.target_temperature = before.target_temperature + 1.0; + after.fan_speed = 3; + let fields = externally_changed_control_fields(&before, &after, &zone); + assert!(fields.iter().any(|field| field == "power")); + assert!(fields.iter().any(|field| field == "target_temperature")); + assert!(fields.iter().any(|field| field == "fan_speed")); + } + + #[test] + fn standby_low_to_auto_fan_drift_is_not_manual_override() { + let mut zone = test_zone("device"); + zone.smart_fan = true; + zone.demand = false; + let mut before = Device::simulated_default(); + before.fan_speed = 1; + let mut after = before.clone(); + after.fan_speed = 0; + assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); + } + + #[test] + fn reset_device_manual_override_clears_takeover_state() { + let mut zone = test_zone("device"); + zone.device_manual_override = true; + zone.device_manual_override_since = Some(Utc::now()); + zone.device_manual_override_until = Some(Utc::now()); + zone.device_manual_override_fields = vec!["target_temperature".into()]; + assert!(reset_device_manual_override(&mut zone)); + assert!(!zone.device_manual_override); + assert!(zone.device_manual_override_since.is_none()); + assert!(zone.device_manual_override_until.is_none()); + assert!(zone.device_manual_override_fields.is_empty()); + } + #[test] fn device_command_drops_unchanged_fields() { let device = Device::simulated_default(); @@ -1603,6 +1875,8 @@ mod tests { #[test] fn smart_quiet_follows_satisfied_transition_only_when_supported() { assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true)); + assert_eq!(smart_quiet_command(true, true, false, false, false, false, false, true), None); + assert_eq!(smart_quiet_command(true, true, false, false, true, false, false, true), None); assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false)); assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None); assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None); @@ -1619,6 +1893,7 @@ mod tests { assert_eq!(night_limited_fan_speed(3, 1), 1); assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true)); assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false)); + assert_eq!(smart_quiet_command(true, true, false, false, true, true, false, true), None); assert_eq!(native_sleep_command(true, true, true, true, false), Some(true)); assert_eq!(native_sleep_command(true, false, true, true, true), Some(false)); assert_eq!(native_sleep_command(true, true, true, false, false), None); diff --git a/src/models.rs b/src/models.rs index e9fec03..82b60d2 100644 --- a/src/models.rs +++ b/src/models.rs @@ -322,6 +322,17 @@ pub struct Zone { pub manual_setpoint: Option, #[serde(default)] pub manual_override_until: 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)] + pub device_manual_override: bool, + #[serde(default)] + pub device_manual_override_since: Option>, + #[serde(default)] + pub device_manual_override_until: Option>, + /// Climate-relevant fields that caused the most recent external/manual takeover. + #[serde(default)] + pub device_manual_override_fields: Vec, #[serde(default)] pub effective_mode: String, #[serde(default)] @@ -380,6 +391,9 @@ pub struct ZoneControlPatch { pub preset: Option, #[serde(default)] pub clear_override: Option, + /// Explicitly hand control of a manually overridden physical unit back to the thermostat engine. + #[serde(default)] + pub clear_device_manual_override: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -680,6 +694,8 @@ pub struct ZoneControlPlan { pub demand: bool, pub control_source: String, pub manual_override_until: Option>, + pub device_manual_override: bool, + pub device_manual_override_until: Option>, pub current_schedule_id: Option, pub current_schedule_name: Option, pub next_events: Vec, diff --git a/web/app.js b/web/app.js index 0ae67cf..c1c9988 100644 --- a/web/app.js +++ b/web/app.js @@ -314,7 +314,10 @@ function renderControlPlan() { const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1); const groupNames = zoneGroups.get(zone.zone_id) || []; const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice')); - return `
${esc(scope)}

${esc(zone.zone_name)}

${esc(zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'))}
${fmtTemp(zone.current_temperature)}${esc(target)}°C

${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; + const planState = zone.device_manual_override + ? tr('zones.manualDeviceControl') + : (zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled')); + return `
${esc(scope)}

${esc(zone.zone_name)}

${esc(planState)}
${fmtTemp(zone.current_temperature)}${esc(target)}°C

${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; }).join(''); const rules = (plan.rules || []).filter(rule => rule.enabled); @@ -614,6 +617,10 @@ function deviceFeaturePanel(device) { return `
${esc(tr('devices.features'))}
${features.map(([field,,label]) => ``).join('')}
`; } +function deviceZoneDisabled(deviceId) { + return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false); +} + function deviceCard(device, detailed = false) { const modes = ['auto','cool','dry','fan','heat']; const fans = [0,1,3,5]; @@ -621,25 +628,28 @@ function deviceCard(device, detailed = false) { const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms'; const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto'); const networkInfo = `
${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}${device.last_error ? `${esc(device.last_error)}` : ''}
`; + const zoneLocked = !detailed && deviceZoneDisabled(device.id); + const locked = zoneLocked ? ' disabled' : ''; const quickClass = detailed ? '' : ' quick-control-card quick-device-control'; const quickRow = detailed ? '' : ' quick-control-row'; return `

${esc(device.name)}

${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

- +
- +
${Number(device.target_temperature).toFixed(1)}°C
- +
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
-
${modes.map(mode => ``).join('')}
-
${fans.map(fan => ``).join('')}
+ ${zoneLocked ? `
${esc(tr('devices.disabledZoneTechnicalOnly'))}
` : ''} +
${modes.map(mode => ``).join('')}
+
${fans.map(fan => ``).join('')}
- - ${device.supports_quiet === false ? '' : ``} - ${device.supports_turbo === false ? '' : ``} + + ${device.supports_quiet === false ? '' : ``} + ${device.supports_turbo === false ? '' : ``}
${detailed ? deviceFeaturePanel(device) : ''} ${detailed ? `` : ''} @@ -688,9 +698,17 @@ function zoneCard(zone, detailed = true) { const mode = zone.inherit_house_mode ? 'house' : zone.mode; const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off'; const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null; - const override = zone.manual_override_until - ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` - : (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')); + const deviceManualOverride = zone.device_manual_override === true; + 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')) + : (zone.manual_override_until + ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` + : (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl'))); + const manualTakeover = deviceManualOverride + ? `
${esc(tr('zones.manualDeviceControl'))}
` + : ''; if (detailed) { const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name); @@ -709,6 +727,7 @@ function zoneCard(zone, detailed = true) {
${esc(tr('zones.smartFan'))}${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}
${esc(sensorDetails)}
+ ${manualTakeover}
`; } @@ -720,7 +739,8 @@ function zoneCard(zone, detailed = true) { ${['auto','comfort','sleep','away'].map(preset=>``).join('')}
-
${zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied'))}${esc(override)}
+
${deviceManualOverride ? esc(tr('zones.manualDeviceControl')) : (zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied')))}${esc(override)}
+ ${manualTakeover} `; } @@ -1657,6 +1677,9 @@ document.addEventListener('click', async event => { } const action = button.dataset.action; if (!action) return; const device = app.devices.find(v => v.id === button.dataset.device); + if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) { + return toast(tr('devices.disabledZoneTechnicalOnly'), true); + } if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power}); if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)}); if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true}); @@ -1677,6 +1700,7 @@ document.addEventListener('click', async event => { const result = await api('/api/house/power',{method:'POST',body:{power}}); app.settings = result.settings || app.settings; app.devices = result.devices || app.devices; + app.groups = result.groups || app.groups; renderAll(); scheduleControlPlanLoad(); const failed = Array.isArray(result.failed) ? result.failed.length : 0; if (failed) toast(tr('house.powerPartial', {count: failed}), true); @@ -1702,6 +1726,7 @@ document.addEventListener('click', async event => { 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}); if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'}); + if (action === 'zone-resume-automation') return sendZoneControl(button.dataset.id,{clear_device_manual_override:true}); if (action === 'zone-go-control') { showView('dashboard', {scroll:false}); const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id); diff --git a/web/index.html b/web/index.html index 6db1060..f58d46b 100644 --- a/web/index.html +++ b/web/index.html @@ -38,7 +38,7 @@
-
Global power

All air conditioners

Master power for all enabled units. Whole-house mode or profile selection automatically switches it back on.

+
Global power

All air conditioners

Master power for thermostat zones. Turn all off also disables all groups; turn all on starts enabled zones only.

diff --git a/web/styles.css b/web/styles.css index 7618818..a530d65 100644 --- a/web/styles.css +++ b/web/styles.css @@ -160,6 +160,9 @@ h3 { margin-bottom: 10px; } .list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; } .badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; } .badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } +.badge.manual-override { color: var(--warning); background: var(--warning-soft); } +.manual-override-row { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; padding:8px 10px; border-radius:10px; background:var(--warning-soft); } +.manual-override-row button { flex:0 0 auto; min-height:30px; padding:5px 9px; } .zone-enable-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; } .zone-enable-toggle.active { border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } .zone-enable-toggle span { font-size: 12px; font-weight: 900; } diff --git a/web/sw.js b/web/sw.js index a263749..a8c474d 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'gree-controller-v073-ha-groups'; +const CACHE = 'gree-controller-v074-manual-override'; 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')];