From f8d6bc2304a4aeb9337c4d3d4ad5935059066134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 26 Aug 2026 22:30:10 +0200 Subject: [PATCH] v0.7.7 --- BUILD_REPORT.md | 42 +++- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 40 ++-- README.md | 24 ++- docs/API.md | 11 +- home-assistant/README.md | 4 + .../gree_controller/climate.py | 3 +- .../gree_controller/coordinator.py | 71 ++++++- .../gree_controller/manifest.json | 2 +- .../gree_controller/switch.py | 3 +- lang/en.json | 5 +- lang/pl.json | 5 +- src/api.rs | 26 ++- src/engine.rs | 193 ++++++++++++++---- src/main.rs | 1 + src/models.rs | 29 ++- src/state.rs | 3 + web/app.js | 16 ++ web/styles.css | 10 + web/sw.js | 2 +- 21 files changed, 410 insertions(+), 84 deletions(-) diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index e221307..07f728b 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,4 +1,4 @@ -# GREE Controller v0.7.5 - build and validation report +# GREE Controller v0.7.7 - build and validation report ## Scope @@ -334,3 +334,43 @@ Validation in this packaging environment: - Validation: `node --check` passed for the web scripts, PL/EN language packs parse with identical 587-key sets, version declarations are consistent and the package SHA-256 manifest verifies. - `cargo check` remains unavailable in this environment because the Rust toolchain is not installed. + +## 2026-08-26 manual takeover, per-unit quick power and restart safety + +Implemented against the supplied v0.7.5 source archive: + +- Manual/remote takeover now stores the pre-takeover physical climate state in the persisted zone payload. +- If the user restores that operational state, the takeover clears automatically. When the previous state was OFF, returning to OFF is sufficient even if the remote retained a different dormant target/mode, so a stale **Resume automation** prompt is not shown. +- If the manual state is not restored, the existing next-schedule-boundary expiry remains unchanged; zones without a future transition still require an explicit resume. +- Dashboard **Quick thermostats** now include per-unit ON/OFF controls. They intentionally use manual/remote semantics, affect only that physical unit and do not enable its climate group. +- Cooling controlled by a valid external/combined room sensor now applies a small pre-rounding demand bias so the unit selects the next lower whole-degree target (normally 0.5-1.0 C below the room target). The bias is disabled during GREE fallback/discrepancy fallback and is not added on top of a larger outdoor-assist correction. +- Startup thermostat/schedule/automation command emission is gated until one complete poll attempt of all enabled devices finishes. This prevents stale persisted device state from producing a restart-only command while leaving manual API control available. +- `/api/health`, bootstrap system data and `/api/system/info` expose `control_ready` for startup diagnostics. +- Advanced the PWA cache key to `gree-controller-v075-zone-manual-power-restart` so changed dashboard assets are refreshed. + +Validation performed in this packaging environment: + +- `node --check web/app.js` - passed. +- `node --check web/sw.js` - passed. +- EN/PL JSON parsing and translation-key parity - passed (590/590 keys). +- Static `tr(...)`/`data-i18n` references in the Web UI - all resolved. +- `web/manifest.webmanifest` JSON parsing - passed. +- `bash -n scripts/*.sh` - passed. +- Home Assistant Python modules and helper Python scripts compiled with `python3 -m py_compile` - passed. +- All direct `Zone { ... }` constructors were updated for the new persisted baseline field. + +Rust compiler note: this packaging environment still does not provide `cargo`, `rustc` or `rustfmt`, so the Rust unit tests and release build cannot be executed here. The normal deployment/update compile gate must still run `cargo test --all-targets` and `cargo build --release` before replacing the running binary. + +## 2026-08-26 Home Assistant state-bounce fix + +- Added bounded post-command GREE status settling retries (0/150/350/650 ms) so an acknowledged write is not immediately contradicted by a firmware status frame that still contains the pre-command state. +- Added a three-second Home Assistant pending-command guard for physical-unit climate and optional-feature switches. Fresh command intent survives overlapping/stale coordinator refreshes, then automatically yields to factual controller polling. +- Kept the guard intentionally short and non-persistent; it does not change restart behavior or permanently mask a failed command. + + +## 2026-08-26 v0.7.7 quick-control desktop label fit + +- Reproduced the dashboard quick-device layout issue from the supplied screenshot: five equal columns left too little usable width after button padding for Polish `Chłodzenie`/`Osuszanie`. +- Rebalanced only the five quick-device mode columns and reduced their horizontal padding from 5 px to 3 px. This keeps all five labels on one line without reducing the shared 9 px control font. +- Verified the Home Assistant integration already exposes every controller zone as an independent climate thermostat with `TURN_ON`/`TURN_OFF`, plus a separate per-zone Enabled switch. Turning on a zone thermostat sends only `enabled: true` for that zone; it does not power the whole group. Physical indoor units also remain independently controllable through their own climate entities. +- Bumped application/Home Assistant package metadata to `0.7.7` and rotated the PWA cache key. diff --git a/Cargo.lock b/Cargo.lock index c902445..3eefd01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.7.5" +version = "0.7.7" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index ce7f462..17906e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.7.5" +version = "0.7.7" 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 e13a473..16ee07e 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,39 +1,39 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -77b2c0f6dc0df29559a574500e32f1455e338d01b3bcc4cd378b1c01d8185f87 ./BUILD_REPORT.md -4d9a67dc86b6599d27bd3163baab3510aa748fb22bb80c09fae514953978d6aa ./Cargo.lock -b049ee42dd074849db4b91a1097343bf482ac1707e7bde1f68eaa0a08ecf4202 ./Cargo.toml +702331da239c3b8ef2166913dfa40340cb8c44a996b03983ccc7490a1089e898 ./BUILD_REPORT.md +401e399e01e9999501f14771d9b5a693b34bb1e51ba33a37b441248b35c60479 ./Cargo.lock +e9336e73b6279a5b58d6577c8784c23f19bc1ae0c0e2c7074e6781dfec5a61e3 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -43b62d076de1e0535342447df837e04bfcef471c5085d83c1625dbd367ea0c22 ./README.md +9abaeb7f6c29ec8775ef6edeb683916323aa17ad83027bbe2b449456d682dd9c ./README.md f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs -eb343972b711426ca1e81684ffdc5f869db003b13afb6fd3828b954f8df7d964 ./docs/API.md +8e72ed628055338d8e9ecc3d9f6929f225fc1ce00fa2e0f19765594a1bcb824f ./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 -a7889fa5880da4c6d6d1b1f63a139051185cfb04769a735ee72c9fd536f1cb62 ./home-assistant/README.md +ada2df9ae4ca93280c71c932849e452fbcae045509f248ca34c9f92bd44400a3 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py -62547d17114dd2ae47881b0789279f2d595285b630af33830244c31074801961 ./home-assistant/custom_components/gree_controller/climate.py +33e5756ff6ca102c9738c61c5119b060a661125dd919bb32e9c1dc04ef4d2c2c ./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 -2eb70cafaeccaf2e62a8de650fed90bac5c8b12be70cf33f80a2f7d8a274a606 ./home-assistant/custom_components/gree_controller/coordinator.py +f31f3909f03fe4be105f89bcb4fe86331b01757288ac342ed0d26b35422d6387 ./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 -d7c856a752efaa354d568e754ec0d2091023b0708bc6a7dbe31521c5f83e253c ./home-assistant/custom_components/gree_controller/manifest.json +b89c0838188944f1054f3885a75cd5a35f4084b6a740f0fa3cb373967b5af61f ./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 -31b8462e00644ce12aa37b849341a95c7498909961d6c35be3d43867904c6c81 ./home-assistant/custom_components/gree_controller/switch.py +2fd8c9ed7a7d24954fb80b7ed9d6426729a19c8342c754aa119b20a18e4ef0eb ./home-assistant/custom_components/gree_controller/switch.py 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -520288cd933e651b989b9e3f049c55bbd196d6b07860a9bdd4deba79c879c79b ./lang/en.json -ac2b2d8d6d0334e542f91fc01304227dcf1e1cb434d81da2875f6df3a674569c ./lang/pl.json +a30a0009e07f79531e1989a08c4585020368f558036f5b50630fa54ea5fe3e36 ./lang/en.json +439e58a61087c67baaae1f7d5d58620cab69fd630e7777c8ab827ad20f109522 ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -46,26 +46,26 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -85947aacd310bcb9dd11f6c674b515dcc9d2b253e95aac942d7f797d741f0c99 ./src/api.rs +65f639252d59eb8e9a5be7f1731768d607f49c9cbe80e005382dabcc3fd828ea ./src/api.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -3ea3a5a92c30772bc3eacb561cc0c1e4ca08e00d6e18014cd0922b94706fcec3 ./src/engine.rs +8585ea7b31e12974907341e959eb98fc1bc5c3993e3b9ccf9f3b2f676c10b953 ./src/engine.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs 081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs -f8d81810acce6f02802e62a455960c77f4d5afca9806e14e87809bd0c88b7e33 ./src/main.rs -c5c2bd15ddbec0bc387682b66c049a53c2164824f19bdaca66d7f3efb305270e ./src/models.rs +2aead1459f334d54970c66f9fb1f04d184f85082525d313cf4d727bdcc089578 ./src/main.rs +82c5c7fb4f8f110583546a3c9134e40f91dbc6ff1abf7cbb21f0adbafaafdd15 ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs a60bd8d23b78c74447546722a03ff11c75367468d3bab9f2f5f9cb56f8e09572 ./src/protocol/gree.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs -800d3dfbae086bec7b6a9419ccb05ec66bbe25dcad43ec0d941714b76ab64eb4 ./src/state.rs +48a001996a8e0f7ab847fad062f6973173fce2766bdfa50cbd49c07bcf628fa5 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -838fed1033349a4bcfb5aa8cdc5e6e95c9a8ace75f33ff6ac4a4ed7f92b279e0 ./web/app.js +7bdca69f08f871b9fcf965751ccc6354704c1b1bf38fd94a04637ebc0bd2e881 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg 5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -99ca6465020d6d9b4728b9c07ef2db659792bff3cda6151371f1e37d3eda5fc8 ./web/styles.css -216883653aa64e5d566bc017ec3ad7bfb84c83a18d720eff6f02c567492b2f1d ./web/sw.js +ba3b35a38d76993140f37a3c1f05aa4271e83d57b365346db1e14d9ca4ee7d02 ./web/styles.css +68578c989fe37be717905eda435fd6d725345c28260aad06fe44bfb1116326ca ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index ab271c7..3c263a0 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.5**. +Current version: **0.7.7**. ## 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.5.zip +unzip gree-controller-v0.7.7.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -218,7 +218,7 @@ For combined control, `max_sensor_difference` protects against an obviously inco The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload. -Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep / Away**, and **Global / Heat / Cool** are available without opening the edit dialog. Temporary overrides automatically end at the next schedule boundary; if a zone has no future schedule transition, the override stays active until it is cleared. The zone engine, not the browser, owns device commands so schedules, sensors and manual overrides cannot fight each other. +Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep / Away**, **Global / Heat / Cool**, and per-unit **ON/OFF** are available without opening the edit dialog. The per-unit power buttons intentionally behave like the physical remote: they affect only that unit, can bypass a disabled group without enabling the whole group, and create the normal manual-device takeover. Temporary thermostat overrides automatically end at the next schedule boundary; if a zone has no future schedule transition, the override stays active until it is cleared. The zone engine, not the browser, owns device commands so schedules, sensors and manual overrides cannot fight each other. ## Smart thermostat and schedules @@ -228,7 +228,9 @@ Choose one seasonal **global mode** — Cooling in summer, Heating in winter, or Schedules store profiles rather than duplicating temperatures. Ready-made **Family**, **Child room**, **Bedroom**, **Workday** and **Always comfort** templates generate ordinary editable schedule entries. For example, a child room template can enter Sleep earlier; if the child goes to bed even earlier, press **Sleep** and that zone remains in Sleep until its next schedule boundary (or until manually cleared when no future boundary exists). Whole-house **Comfort all / Sleep all / Away all / Auto schedule** actions are also available. -The optional HA outdoor sensor is an assist signal only. It never replaces the room-control temperature. In extreme weather it can make the active device setpoint slightly more assertive and let Smart Fan increase airflow. +The optional HA outdoor sensor is an assist signal only. It never replaces the room-control temperature. In extreme weather it can make the active device setpoint slightly more assertive and let Smart Fan increase airflow. When cooling is actually controlled from a valid external/combined room sensor, demand also forces the next lower whole-degree unit target (typically 0.5–1.0°C below the room target); the correction is disabled on GREE fallback and is not stacked above the outdoor-assist maximum. + +At startup, thermostat, schedule and automation commands are held until one complete live device poll has finished. This prevents a restart from issuing commands based only on stale persisted device state while still preserving all saved zone, group, schedule, override and device configuration. ## Home Assistant @@ -447,6 +449,11 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C - 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.6 Home Assistant command-state stability + +- Physical-unit commands now tolerate the short GREE firmware settling window after a successful command ACK before publishing a contradictory status. This prevents stale immediate polls from producing visible `ON -> OFF -> ON` state bounce. +- The Home Assistant coordinator keeps a just-accepted device command as a short-lived pending value while refreshing controller metadata. An overlapping stale read cannot temporarily roll back the HA climate/feature state; normal factual polling resumes automatically after the bounded guard window. + ## 0.7.5 group power state visibility - Group **ON/OFF** buttons now use the same strong active-state highlight as group mode and profile controls, so the current power gate is immediately visible. @@ -456,8 +463,15 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C - 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. +- The pre-takeover climate state is remembered. If the user returns the unit to that operational state, takeover clears automatically and **Resume automation** is no longer shown. If the unit was OFF before takeover, returning it to OFF is enough even when the remote retained a different dormant setpoint. Otherwise takeover expires at the next schedule transition when available; without one 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. + + +## 0.7.7 Quick-control label fit + +- Adjusted the five-column quick-device mode row so longer Polish labels such as **Chłodzenie** and **Osuszanie** receive more width without reducing the whole row font size. +- The change is limited to the dashboard quick-device mode row; thermostat/group layouts are unchanged. +- Rotated the PWA cache key so browsers fetch the corrected stylesheet after upgrade. diff --git a/docs/API.md b/docs/API.md index 633d39f..3adc18e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -124,13 +124,18 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl # Temporary custom room target curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"setpoint":22.5}' + +# Manual power for only this zone's physical unit. This behaves like a remote and +# does not enable the containing climate group. +curl -X POST "$BASE/api/zones/ZONE_ID/manual-power" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"power":true}' ``` `mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow global mode** in the UI and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones. ### 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 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 controller stores the pre-takeover climate state; if the user later returns the unit to that operational state, the takeover is cleared automatically and the stale **Resume automation** prompt disappears. When the pre-takeover state was OFF, switching the unit OFF again is sufficient even if the remote kept a different dormant target/mode internally. Otherwise 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: @@ -202,6 +207,10 @@ This setting is opt-in and applies only to the controller's outbound Home Assist ## Current control plan +When cooling is driven by a valid per-zone Home Assistant room sensor (`external` or `combined` control source), active demand forces the next lower whole-degree unit target (typically 0.5-1.0 C below the room target). It is not stacked on top of a larger outdoor-assist correction, and it is disabled automatically whenever control falls back to the GREE sensor. This helps the independent room sensor reach the requested temperature without increasing overcooling risk during sensor failures. + +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 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 diff --git a/home-assistant/README.md b/home-assistant/README.md index 6787855..06db60b 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -150,3 +150,7 @@ Every group configured in **GREE Controller -> Groups** is published as a separa If member zones have been changed individually and no longer share one mode or profile, the matching select has no current common value instead of reporting a misleading state. Selecting a group mode/profile applies it to every member through the existing controller group logic. After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created. + +## Command state stability (0.7.6) + +Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state. diff --git a/home-assistant/custom_components/gree_controller/climate.py b/home-assistant/custom_components/gree_controller/climate.py index 5ebc00e..e8d00c8 100644 --- a/home-assistant/custom_components/gree_controller/climate.py +++ b/home-assistant/custom_components/gree_controller/climate.py @@ -223,8 +223,7 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat } async def _command(self, payload: dict[str, Any]) -> None: - await self.coordinator.client.command(self._device_id, payload) - await self.coordinator.async_request_refresh() + await self.coordinator.async_device_command(self._device_id, payload) async def async_turn_on(self) -> None: await self._command({"power": True}) diff --git a/home-assistant/custom_components/gree_controller/coordinator.py b/home-assistant/custom_components/gree_controller/coordinator.py index 0232f21..c55eb1d 100644 --- a/home-assistant/custom_components/gree_controller/coordinator.py +++ b/home-assistant/custom_components/gree_controller/coordinator.py @@ -31,6 +31,9 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.client = client self.plan: dict[str, Any] = {} self.groups: dict[str, dict[str, Any]] = {} + # Protect a just-accepted device command from an overlapping/stale poll. + # GREE units can expose their previous status briefly after acknowledging a write. + self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {} async def _async_update_data(self) -> dict[str, dict]: try: @@ -43,4 +46,70 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]): raise UpdateFailed(str(err)) from err self.plan = plan self.groups = {str(group["id"]): group for group in groups if group.get("id")} - return {str(device["id"]): device for device in devices if device.get("id")} + device_map = {str(device["id"]): device for device in devices if device.get("id")} + self._overlay_pending_device_commands(device_map) + return device_map + + @staticmethod + def _normalized_device_command(payload: dict[str, Any]) -> dict[str, Any]: + """Normalize writable fields exactly as the standalone controller does.""" + allowed = { + "power", "mode", "target_temperature", "fan_speed", + "swing_vertical", "swing_horizontal", "quiet", "turbo", + "light", "air", "xfan", "health", "sleep", + } + expected = {key: value for key, value in payload.items() if key in allowed} + if "target_temperature" in expected: + value = max(8.0, min(30.0, float(expected["target_temperature"]))) + expected["target_temperature"] = float(int(value + 0.5)) + if "fan_speed" in expected: + expected["fan_speed"] = min(5, max(0, int(expected["fan_speed"]))) + return expected + + def _overlay_pending_device_commands(self, devices: dict[str, dict[str, Any]]) -> None: + """Do not let a stale post-command poll make HA controls bounce backwards.""" + now = asyncio.get_running_loop().time() + for device_id, (deadline, expected) in list(self._pending_device_commands.items()): + if now >= deadline: + self._pending_device_commands.pop(device_id, None) + continue + device = devices.get(device_id) + if device is not None: + device.update(expected) + + async def async_device_command(self, device_id: str, payload: dict[str, Any]) -> None: + """Send a physical-unit command while keeping HA state monotonic during settling.""" + expected = self._normalized_device_command(payload) + loop = asyncio.get_running_loop() + + # Register intent before network I/O. A coordinator refresh that was already in flight + # when the user clicked must not be allowed to publish the pre-command state. + self._pending_device_commands[device_id] = (loop.time() + 5.0, expected) + current = dict(self.data or {}) + if device_id in current: + optimistic = dict(current[device_id]) + optimistic.update(expected) + current[device_id] = optimistic + self.async_set_updated_data(current) + + try: + device = await self.client.command(device_id, payload) + except GreeControllerApiError: + # Never mask an actual rejected/failed command. Drop the optimistic guard and + # immediately restore the latest factual controller state. + self._pending_device_commands.pop(device_id, None) + await self.async_request_refresh() + raise + + # Keep the guard briefly after a successful ACK so delayed firmware status and an + # overlapping coordinator refresh cannot roll the entity backwards. + self._pending_device_commands[device_id] = (loop.time() + 3.0, expected) + current = dict(self.data or {}) + optimistic = dict(device) + optimistic.update(expected) + current[device_id] = optimistic + self.async_set_updated_data(current) + + # Refresh plan/group metadata immediately as before. Any stale device snapshot in + # this refresh is overlaid by the short pending-command guard above. + await self.async_request_refresh() diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index fd92b1a..adcc0e2 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.5", + "version": "0.7.7", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/home-assistant/custom_components/gree_controller/switch.py b/home-assistant/custom_components/gree_controller/switch.py index 10cb740..1fb4b74 100644 --- a/home-assistant/custom_components/gree_controller/switch.py +++ b/home-assistant/custom_components/gree_controller/switch.py @@ -225,8 +225,7 @@ class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordina ) async def _set(self, value: bool) -> None: - await self.coordinator.client.command(self._device_id, {self._field: value}) - await self.coordinator.async_request_refresh() + await self.coordinator.async_device_command(self._device_id, {self._field: value}) async def async_turn_on(self, **kwargs: Any) -> None: await self._set(True) diff --git a/lang/en.json b/lang/en.json index dc6ae4c..eeb83df 100644 --- a/lang/en.json +++ b/lang/en.json @@ -592,6 +592,9 @@ "zones.manualDeviceDescription": "A setting change was detected directly on the air conditioner or remote. Automation for this zone is paused.", "zones.manualDeviceUntil": "Manual control / remote until {time}", "zones.manualDeviceNoBoundary": "Manual control / remote · until resumed", - "zones.resumeAutomation": "Resume automation" + "zones.resumeAutomation": "Resume automation", + "zones.unitPowerManual": "Unit · remote", + "zones.unitPowerManualHint": "Turns only this unit on or off like a remote; it does not enable the whole group.", + "zones.unitPowerUpdated": "Single-unit power updated" } } diff --git a/lang/pl.json b/lang/pl.json index 4c1e77a..909070d 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -592,6 +592,9 @@ "zones.manualDeviceDescription": "Wykryto zmianę ustawień bezpośrednio na klimatyzatorze lub pilotem. Automatyka tej strefy jest wstrzymana.", "zones.manualDeviceUntil": "Sterowanie ręczne / pilot do {time}", "zones.manualDeviceNoBoundary": "Sterowanie ręczne / pilot · do wznowienia", - "zones.resumeAutomation": "Wznów automatykę" + "zones.resumeAutomation": "Wznów automatykę", + "zones.unitPowerManual": "Jednostka · pilot", + "zones.unitPowerManualHint": "Włącza lub wyłącza tylko tę jednostkę jak pilot; nie włącza całej grupy.", + "zones.unitPowerUpdated": "Zmieniono zasilanie pojedynczej jednostki" } } diff --git a/src/api.rs b/src/api.rs index d439f4b..4cb41e8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -50,6 +50,7 @@ pub fn router(state: AppState) -> Router { .route("/api/zones", get(list_zones).post(create_zone)) .route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone)) .route("/api/zones/:id/control", post(update_zone_control)) + .route("/api/zones/:id/manual-power", post(update_zone_manual_power)) .route("/api/zones/:id/schedule-template", post(apply_schedule_template)) .route("/api/groups", get(list_groups).post(create_group)) .route("/api/groups/:id", get(get_group).put(update_group).delete(delete_group)) @@ -197,6 +198,7 @@ async fn health(State(state): State) -> Json { "name": "gree-controller", "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": state.started.elapsed().as_secs(), + "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), "time": Utc::now(), })) } @@ -220,6 +222,7 @@ async fn build_bootstrap(state: &AppState) -> Result { "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": state.started.elapsed().as_secs(), "auth_required": !state.config.app_token.trim().is_empty(), + "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), } })) } @@ -233,6 +236,7 @@ async fn system_info(State(state): State) -> Result, AppEr "device_count": devices.len(), "online_count": devices.iter().filter(|v| v.online).count(), "simulator_count": devices.iter().filter(|v| v.simulated).count(), + "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), "bind": state.config.bind.to_string(), "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, }))) @@ -527,7 +531,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(), + 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, created_at, updated_at: Utc::now(), @@ -577,6 +581,7 @@ async fn update_zone(State(state): State, Path(id): Path, Json 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.device_manual_override_baseline = existing.device_manual_override_baseline; zone.effective_mode = existing.effective_mode; zone.effective_setpoint = existing.effective_setpoint; zone.device_setpoint = existing.device_setpoint; @@ -665,6 +670,25 @@ async fn update_zone_control(State(state): State, Path(id): Path, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { + let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let device = engine::send_manual_command( + &state, + &zone.device_id, + DeviceCommand { power: Some(input.power), ..Default::default() }, + "zone.quick_manual_power", + ).await?; + let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + Ok(Json(json!({"zone": zone, "device": device}))) +} + async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; if !device.enabled { diff --git a/src/engine.rs b/src/engine.rs index 7fb2b07..1954d5d 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, time::{Duration, Instant}}; +use std::{collections::HashMap, sync::atomic::Ordering, time::{Duration, Instant}}; use anyhow::Result; use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; use serde_json::{json, Value}; @@ -16,8 +16,13 @@ pub fn start(state: AppState) { tokio::spawn(async move { sleep(Duration::from_millis(500)).await; loop { - if let Err(err) = poll_all(&poll_state).await { - tracing::error!(error=?err, "device poll cycle failed"); + match poll_all(&poll_state).await { + Ok(()) => { + if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) { + tracing::info!("initial device state synchronized; thermostat control enabled"); + } + } + Err(err) => tracing::error!(error=?err, "device poll cycle failed"), } let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2); sleep(Duration::from_secs(seconds)).await; @@ -28,6 +33,13 @@ pub fn start(state: AppState) { tokio::spawn(async move { sleep(Duration::from_secs(2)).await; loop { + // A restart must never make decisions from the persisted, potentially stale + // device snapshot. Wait for one full live poll before thermostat/schedule/automation + // ownership can emit commands. Manual API/remote control remains available. + if !control_state.initial_device_sync_complete.load(Ordering::Acquire) { + sleep(Duration::from_millis(250)).await; + continue; + } if let Err(err) = control_zones(&control_state).await { tracing::error!(error=?err, "zone cycle failed"); } @@ -198,27 +210,40 @@ async fn send_command_locked_inner( if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); } if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); } - // A command ACK confirms transport/acceptance, not the resulting climate state. Read - // status before publishing device_setpoint/power/mode as factual. If verification is - // unavailable, keep the previous confirmed values and mark communication uncertainty. + // A command ACK confirms transport/acceptance, but several GREE firmwares keep + // returning the pre-command status for a short settling window. Publishing that first + // stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times + // with bounded backoff and only publish a differing state after the settling window. if !confirmed_state { - let mut observed = device.clone(); - match state.gree.poll(&mut observed).await { - Ok(()) => { - if !applied_command.changed_from(&observed).is_empty() { - confirmed_requested_state = false; - tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but verified status differs"); + let verification_delays_ms = [0_u64, 150, 350, 650]; + let mut last_verification_error: Option = None; + for delay_ms in verification_delays_ms { + if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } + let mut observed = device.clone(); + match state.gree.poll(&mut observed).await { + Ok(()) => { + let requested_matches = applied_command.changed_from(&observed).is_empty(); + device = observed; + confirmed_state = true; + confirmed_requested_state = requested_matches; + last_verification_error = None; + if requested_matches { break; } + } + Err(err) => { + last_verification_error = Some(err.to_string()); } - device = observed; - confirmed_state = true; - } - Err(err) => { - record_poll_failure(&mut device, &format!("command accepted but status verification failed: {err}")); - state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({ - "device_id": device.id, "error": err.to_string() - })); } } + + if confirmed_state && !confirmed_requested_state { + tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window"); + } else if !confirmed_state { + let error = last_verification_error.unwrap_or_else(|| "status verification failed".into()); + record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}")); + state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({ + "device_id": device.id, "error": error + })); + } } if confirmed_state { device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); @@ -446,19 +471,56 @@ 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_fields.is_empty() + || zone.device_manual_override_baseline.is_some(); zone.device_manual_override = false; zone.device_manual_override_since = None; zone.device_manual_override_until = None; zone.device_manual_override_fields.clear(); + zone.device_manual_override_baseline = None; changed } -fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec, source: &str) -> Result<(), AppError> { +fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool { + let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; }; + if zone.device_manual_override_fields.is_empty() { return false; } + // If the unit was OFF before takeover, returning it to OFF is operationally the same + // controller state even if the remote retained a different mode/target internally. + // Those dormant values will be set explicitly if automation later powers the unit. + if !baseline.power { return !device.power; } + zone.device_manual_override_fields.iter().all(|field| match field.as_str() { + "power" => device.power == baseline.power, + "mode" => device.mode == baseline.mode, + "target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(), + "fan_speed" => device.fan_speed == baseline.fan_speed, + "quiet" => device.quiet == baseline.quiet, + "sleep" => device.sleep == baseline.sleep, + _ => false, + }) +} + +fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result { + if !reset_device_manual_override(zone) { return Ok(false); } + zone.updated_at = Utc::now(); + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + let (kind, message) = if restored { + ("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name)) + } else { + ("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name)) + }; + state.log("info", kind, &message, json!({ + "zone_id": zone.id, "device_id": zone.device_id, "source": source + })); + Ok(true) +} + +fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec, source: &str, baseline: &Device) -> 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_baseline = Some(baseline.into()); } zone.device_manual_override = true; zone.device_manual_override_until = if zone.enabled { @@ -466,7 +528,11 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec Result<()> { // Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor // stop naturally when we move the target to the satisfied side of room temperature. - let assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); + let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); + // When an independent room sensor is actually driving cooling, the indoor unit's + // own sensor can satisfy too early. Apply a half-degree pre-rounding bias: because + // GREE setpoints are sent as whole degrees, this selects the next lower whole-degree + // target (0.5-1.0 C below the room target). Do not stack it with outdoor assist and + // do not use it during device/fallback control. + let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source); + let demand_assist = outdoor_assist.max(room_sensor_assist); let active_target = match effective_mode { - "heat" => target + assist, - _ => target - assist, + "heat" => target + outdoor_assist, + _ => target - demand_assist, }; let standby_target = match effective_mode { "heat" => target - zone.standby_offset_c.max(0.5), @@ -1326,6 +1396,10 @@ fn adjustment_allowed(zone: &Zone) -> bool { (Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15) } +fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 { + if mode == "cool" && matches!(control_source, "external" | "combined") { 0.5 } else { 0.0 } +} + fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 { let value = value.clamp(16.0, 30.0); match (mode, demand) { @@ -2015,7 +2089,7 @@ mod tests { 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, - device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), + 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: "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(), } @@ -2060,15 +2134,37 @@ mod tests { #[test] fn reset_device_manual_override_clears_takeover_state() { let mut zone = test_zone("device"); + let device = Device::simulated_default(); 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()]; + zone.device_manual_override_baseline = Some((&device).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()); + assert!(zone.device_manual_override_baseline.is_none()); + } + + #[test] + fn restored_manual_device_state_matches_original_takeover_baseline() { + let mut zone = test_zone("device"); + let baseline = Device::simulated_default(); + zone.device_manual_override = true; + zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()]; + zone.device_manual_override_baseline = Some((&baseline).into()); + + let mut changed = baseline.clone(); + changed.power = !baseline.power; + changed.target_temperature = baseline.target_temperature + 2.0; + assert!(!manual_override_matches_baseline(&zone, &changed)); + + let mut returned_off = baseline.clone(); + returned_off.target_temperature = baseline.target_temperature + 3.0; + assert!(manual_override_matches_baseline(&zone, &returned_off)); + assert!(manual_override_matches_baseline(&zone, &baseline)); } #[test] @@ -2152,6 +2248,15 @@ mod tests { assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0); } + #[test] + fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() { + assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 0.5); + assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 0.5); + assert_eq!(external_room_sensor_cooling_assist("cool", "device_fallback"), 0.0); + assert_eq!(external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"), 0.0); + assert_eq!(external_room_sensor_cooling_assist("heat", "external"), 0.0); + } + #[test] fn smart_fan_uses_low_speed_when_zone_is_satisfied() { assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1); diff --git a/src/main.rs b/src/main.rs index 576e4af..e19d1b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,7 @@ async fn main() -> Result<()> { http, outdoor_temperature: Arc::new(RwLock::new(None)), debug_gree_frames, + initial_device_sync_complete: Arc::new(AtomicBool::new(false)), device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), started: Instant::now(), }; diff --git a/src/models.rs b/src/models.rs index 050d395..306b478 100644 --- a/src/models.rs +++ b/src/models.rs @@ -244,6 +244,29 @@ impl DeviceCommand { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManualDeviceBaseline { + pub power: bool, + pub mode: String, + pub target_temperature: f64, + pub fan_speed: u8, + pub quiet: bool, + pub sleep: bool, +} + +impl From<&Device> for ManualDeviceBaseline { + fn from(device: &Device) -> Self { + Self { + power: device.power, + mode: device.mode.clone(), + target_temperature: device.target_temperature, + fan_speed: device.fan_speed, + quiet: device.quiet, + sleep: device.sleep, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Zone { pub id: String, @@ -330,9 +353,13 @@ pub struct Zone { 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. + /// Climate-relevant fields changed during the current external/manual takeover. #[serde(default)] pub device_manual_override_fields: Vec, + /// Controller-observed climate state immediately before the takeover started. + /// It lets us drop a stale "resume automation" prompt when the user restores that state. + #[serde(default)] + pub device_manual_override_baseline: Option, #[serde(default)] pub effective_mode: String, #[serde(default)] diff --git a/src/state.rs b/src/state.rs index 8233b31..9245f12 100644 --- a/src/state.rs +++ b/src/state.rs @@ -14,6 +14,9 @@ pub struct AppState { pub http: reqwest::Client, pub outdoor_temperature: Arc>>, pub debug_gree_frames: Arc, + /// Thermostat/automation control stays passive until every enabled device has had + /// one startup poll, preventing stale persisted device state from causing restart commands. + pub initial_device_sync_complete: Arc, pub(crate) device_operation_locks: Arc>>>>, pub started: Instant, } diff --git a/web/app.js b/web/app.js index 02b0135..6a880de 100644 --- a/web/app.js +++ b/web/app.js @@ -709,6 +709,8 @@ function zoneCard(zone, detailed = true) { const manualTakeover = deviceManualOverride ? `
${esc(tr('zones.manualDeviceControl'))}

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

` : ''; + const manualPowerDisabled = !device || device.enabled === false ? ' disabled' : ''; + const manualUnitPower = `
${esc(tr('zones.unitPowerManual'))}
`; if (detailed) { const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name); @@ -734,6 +736,7 @@ function zoneCard(zone, detailed = true) { return `

${esc(zone.name)}

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

+ ${manualUnitPower}
${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target)?target.toFixed(1):'--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
${['auto','comfort','sleep','away'].map(preset=>``).join('')} @@ -1111,6 +1114,18 @@ function updateDevice(device) { if (index >= 0) app.devices[index] = device; else app.devices.push(device); } +async function sendZoneManualPower(id, power) { + try { + const result = await api(`/api/zones/${encodeURIComponent(id)}/manual-power`, {method:'POST', body:{power}}); + if (result.device) updateDevice(result.device); + if (result.zone) { + const index = app.zones.findIndex(item => item.id === result.zone.id); + if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone); + } + renderAll(); scheduleControlPlanLoad(); toast(tr('zones.unitPowerUpdated')); + } catch (error) { toast(error.message, true); } +} + async function sendZoneControl(id, patch) { const sequence = (app.zoneControlSeq[id] || 0) + 1; app.zoneControlSeq[id] = sequence; @@ -1724,6 +1739,7 @@ document.addEventListener('click', async event => { if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'}); if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value}); if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); + if (action === 'zone-device-power') return sendZoneManualPower(button.dataset.id, button.dataset.value === 'true'); if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; } if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value}); if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value}); diff --git a/web/styles.css b/web/styles.css index 7043555..2106fa1 100644 --- a/web/styles.css +++ b/web/styles.css @@ -752,9 +752,16 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36 overflow:visible; padding:2px 0 5px; } +.quick-control-card .quick-control-row-2 { grid-template-columns:repeat(2,minmax(0,1fr)); } .quick-control-card .quick-control-row-3 { grid-template-columns:repeat(3,minmax(0,1fr)); } .quick-control-card .quick-control-row-4 { grid-template-columns:repeat(4,minmax(0,1fr)); } .quick-control-card .quick-control-row-5 { grid-template-columns:repeat(5,minmax(0,1fr)); } +/* Polish mode labels are noticeably wider than Auto/Nawiew/Grzanie. + Give Cool/Dry more room instead of shrinking or clipping their text. */ +.quick-device-control .mode-row.quick-control-row-5 { + grid-template-columns:minmax(0,.8fr) minmax(0,1.35fr) minmax(0,1.2fr) minmax(0,.85fr) minmax(0,.8fr); +} +.quick-device-control .mode-row.quick-control-row-5 button { padding-inline:3px; } .quick-control-card .quick-control-row button { min-width:0; min-height:var(--quick-control-height); @@ -773,6 +780,9 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36 .quick-device-control .temperature-control { gap:12px; padding:12px 0 10px; } .quick-device-control .current-line { margin:-3px 0 10px; font-size:11px; } .quick-device-control .device-toggles.quick-control-row { margin-top:1px; } +.quick-thermostat-control .zone-manual-power { margin-top:6px; } +.quick-thermostat-control .zone-manual-power > small { display:block; margin-bottom:2px; color:var(--muted); font-size:9px; } +.quick-thermostat-control .zone-manual-power button.active { color:var(--accent-text); background:var(--accent); font-weight:800; } .quick-thermostat-control .thermostat-main { margin:8px 0 7px; } .quick-thermostat-control .zone-enable-toggle { min-height:28px; padding:4px 8px; font-size:10px; } diff --git a/web/sw.js b/web/sw.js index f2bdf90..df827a4 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'gree-controller-v075-group-power-state'; +const CACHE = 'gree-controller-v077-mode-label-fit'; 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')];