This commit is contained in:
Mateusz Gruszczyński
2026-08-27 14:16:56 +02:00
parent 584bdca7c6
commit 7102d05cb3
21 changed files with 305 additions and 65 deletions
+26 -2
View File
@@ -1,8 +1,8 @@
# GREE Controller v0.7.8 - build and validation report # GREE Controller v0.7.9 - build and validation report
## Scope ## Scope
Version 0.6.7 includes the previous climate-group and whole-house changes plus a reorganized main dashboard. Global controls are consolidated, group control stays immediately below them, and lower-priority dashboard sections are collapsed on demand. Version 0.7.9 includes the previous climate-group and whole-house changes plus the zone-profile and Home Assistant stability fixes described below. Global controls are consolidated, group control stays immediately below them, and lower-priority dashboard sections are collapsed on demand.
## Implemented changes ## Implemented changes
@@ -381,3 +381,27 @@ Rust compiler note: this packaging environment still does not provide `cargo`, `
- Replaced the misleading thermostat runtime labels **Potrzeba komfortu** / **Komfort / standby inwertera** with **Zapotrzebowanie aktywne** / **Cel osiągnięty**. The Comfort wording remains reserved for the actual Comfort preset/profile. Runtime status also reports **Oczekiwanie** when thermostat control is paused by mode and **Brak pomiaru** when no valid control temperature is available, so **Cel osiągnięty** is not used as a generic idle state. - Replaced the misleading thermostat runtime labels **Potrzeba komfortu** / **Komfort / standby inwertera** with **Zapotrzebowanie aktywne** / **Cel osiągnięty**. The Comfort wording remains reserved for the actual Comfort preset/profile. Runtime status also reports **Oczekiwanie** when thermostat control is paused by mode and **Brak pomiaru** when no valid control temperature is available, so **Cel osiągnięty** is not used as a generic idle state.
- Simplified per-unit manual power in Dashboard -> Quick thermostats: removed the redundant **Jednostka · pilot** caption and the separate ON/OFF pair. A single contextual **Włącz jednostkę** / **Wyłącz jednostkę** button now directly controls only that physical unit with the existing manual-takeover semantics. - Simplified per-unit manual power in Dashboard -> Quick thermostats: removed the redundant **Jednostka · pilot** caption and the separate ON/OFF pair. A single contextual **Włącz jednostkę** / **Wyłącz jednostkę** button now directly controls only that physical unit with the existing manual-takeover semantics.
- Bumped package/Home Assistant metadata to `0.7.8` and rotated the PWA cache key. - Bumped package/Home Assistant metadata to `0.7.8` and rotated the PWA cache key.
## 2026-08-27 v0.7.9 zone profile and Home Assistant fixes
- Explicit quick-zone target/mode/profile/enable actions now refresh the runtime profile target immediately and wake the regulator instead of waiting for the next fixed thermostat interval.
- Manual/remote takeover no longer exposes a physical standby target (for example 25 C) as the zone thermostat target. The thermostat profile target and physical `device_setpoint` are reported separately.
- Control-plan `enabled` is now the configured per-zone switch; new `effective_enabled` reports disabled-group gating without making the Home Assistant zone switch bounce to Off.
- Home Assistant zone commands have a short pending-state guard against stale overlapping refreshes.
- Zone climate entities support Auto/Comfort/Sleep/Away presets and each zone device has a dedicated **Work profile** select.
- Added a focused Rust unit test for immediate manual-profile target refresh.
Validation for this package is recorded after the final archive/manifest checks. Rust compile/test still requires the normal target/CI host because this packaging environment has no Rust toolchain.
## Final validation for v0.7.9
- `node --check` passed for all Web UI JavaScript files.
- Home Assistant modules, helper scripts and `make_zip.py` passed `python3 -m py_compile`.
- All JSON files parsed successfully; Polish and English language packs have identical flattened 597-key sets.
- All shell scripts passed `bash -n`; `web/index.html` parsed successfully and CSS braces are balanced.
- Cargo package, Home Assistant manifest, README and PWA cache metadata consistently report version `0.7.9`.
- Rust source delimiter/static invariant checks passed, including the new regulator wake-up and control-plan fields.
- `FILE_MANIFEST.sha256` is regenerated from the final tree and verified before packaging; the resulting ZIP is tested with `unzip -t`.
- This environment does not provide `cargo`, `rustc` or `rustfmt`, so the normal target/CI update path must still run `cargo test --all-targets` and `cargo build --release` before replacing the running binary.
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.7.8" version = "0.7.9"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.7.8" version = "0.7.9"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+20 -20
View File
@@ -1,33 +1,33 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
ff0beb45aafde9e919deaa38a8900c8bc09ae5a853e1a17ef6a2b596eaa38a4e ./BUILD_REPORT.md eb1d764a4d0622db45bcdfde3e7c94075707a375ae62c3e316678fce86838551 ./BUILD_REPORT.md
8d644882219821d356fdc1e65f2dfd6e15e0ac6ad4d83b6cc9e99b914d28afe1 ./Cargo.lock e07c746bbf859c3a36e48d27fd381c29e2c56086fa1df68ecc30eea8a73fc335 ./Cargo.lock
5f372aa21533c57499cb6ce87db5708e5127bd8021b1113a0cc5393a8c74dd8e ./Cargo.toml bc5a32e369cbf84dfb12faa2f0a70c53e2034ade769c0dfa88b04b645a755864 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
8d296b79780b105cce00d1ca447727316bb3363984129740ac1c6d3868b3b0b6 ./README.md 46b7e09ab96f428ff6d5eb8a95a340d827af7f986176ef35c5a3e3111e3bf7d3 ./README.md
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
8e72ed628055338d8e9ecc3d9f6929f225fc1ce00fa2e0f19765594a1bcb824f ./docs/API.md f19897119089a4c6415bc35368eb9e5607aa7cafc6360c87812b1c662f30fadf ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md 2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md 7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md 28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
ada2df9ae4ca93280c71c932849e452fbcae045509f248ca34c9f92bd44400a3 ./home-assistant/README.md 21e899f338b5f1d8267a0096293750cf240e4b35a893c9680af56515ad9f7320 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
33e5756ff6ca102c9738c61c5119b060a661125dd919bb32e9c1dc04ef4d2c2c ./home-assistant/custom_components/gree_controller/climate.py 8c10cb10b31357d9eee371e1d1c078b15fb27b2c2cdbdbac35b049762737de22 ./home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py 5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py
f31f3909f03fe4be105f89bcb4fe86331b01757288ac342ed0d26b35422d6387 ./home-assistant/custom_components/gree_controller/coordinator.py 923f68c5ee8def4443749d891599d7176e1a38c5a99552ccbe3502e34b425925 ./home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.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/icon.png
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
f37d31b0b823a6ee516ebc34599c52e19dafcf1e7074253d08913587f3c5294c ./home-assistant/custom_components/gree_controller/manifest.json 67c87a0dbc7a3ed7ad617ddbd6996de7b6c4a40653745ad310ec0cfe36860e45 ./home-assistant/custom_components/gree_controller/manifest.json
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./home-assistant/custom_components/gree_controller/select.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
047f3db03f0db54a40d63092370e5861510d611edfb0a0659e4c25b949d47854 ./home-assistant/custom_components/gree_controller/sensor.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py
2fd8c9ed7a7d24954fb80b7ed9d6426729a19c8342c754aa119b20a18e4ef0eb ./home-assistant/custom_components/gree_controller/switch.py 229ef9b3de8001bdedea42015a219c7f7c17d50f90b10705508494d434aeb549 ./home-assistant/custom_components/gree_controller/switch.py
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
@@ -46,26 +46,26 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
65f639252d59eb8e9a5be7f1731768d607f49c9cbe80e005382dabcc3fd828ea ./src/api.rs 1f131ce502b6e11057e0522d44fec484ac0af769a6af6c4a0c7fa420567faad9 ./src/api.rs
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
8585ea7b31e12974907341e959eb98fc1bc5c3993e3b9ccf9f3b2f676c10b953 ./src/engine.rs 780ba1214043b2894232eebebc9c89dcbfd33cd4f6d263baacb4c42bbcc6692e ./src/engine.rs
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs 081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
2aead1459f334d54970c66f9fb1f04d184f85082525d313cf4d727bdcc089578 ./src/main.rs 708353b36fd1604c10fd81898c1b7abf463ee1271544e27f38a4b51291601b36 ./src/main.rs
82c5c7fb4f8f110583546a3c9134e40f91dbc6ff1abf7cbb21f0adbafaafdd15 ./src/models.rs 20268f5bc184eee0ce7272ffc52c07ff5909eea2f866f798a20f4a4620fbac3c ./src/models.rs
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
a60bd8d23b78c74447546722a03ff11c75367468d3bab9f2f5f9cb56f8e09572 ./src/protocol/gree.rs a60bd8d23b78c74447546722a03ff11c75367468d3bab9f2f5f9cb56f8e09572 ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
48a001996a8e0f7ab847fad062f6973173fce2766bdfa50cbd49c07bcf628fa5 ./src/state.rs 115a386a76db7e6182bae58067d43c11ef5bf9aae6fe9ac149f13c7191a5812a ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
b04c9a8d013a1d512cb5246b76ae84ca20770a4561cfa23f51109db01e6a468f ./web/app.js 59aa9e04b0251775ee6460e04989720866756fde7cc1378fa0f09fa4b6d00be1 ./web/app.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html 5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
d4369e34b5392be142c038f5fc68343bd3514a8b34aa8503f1ed2ed700737299 ./web/styles.css d4369e34b5392be142c038f5fc68343bd3514a8b34aa8503f1ed2ed700737299 ./web/styles.css
47189dc2da72f976e5e868fcc82284f7ee45b47747812ac08f54872e6d5b758d ./web/sw.js 3ec8109e706047d95387deb11af80164e97d5a6f0ad93cc70a1a71d6eae194a0 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+10 -2
View File
@@ -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. 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.8**. Current version: **0.7.9**.
## Highlights ## 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: On Debian, Ubuntu or an LXC container:
```bash ```bash
unzip gree-controller-v0.7.8.zip unzip gree-controller-v0.7.9.zip
cd gree-controller cd gree-controller
chmod +x scripts/*.sh chmod +x scripts/*.sh
./scripts/dev.sh ./scripts/dev.sh
@@ -477,6 +477,14 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C
- Rotated the PWA cache key so browsers fetch the corrected stylesheet after upgrade. - Rotated the PWA cache key so browsers fetch the corrected stylesheet after upgrade.
## 0.7.9 Zone profile control and Home Assistant stability
- Quick thermostat profile/mode/target/enable actions now wake the thermostat regulator immediately instead of waiting for the next fixed zone interval.
- A manual/remote takeover keeps the configured thermostat profile target visible; the physical GREE target is reported separately as the device setpoint. This prevents standby setpoints such as 25 C from being shown as the Sleep/Comfort target.
- Control-plan `enabled` now represents the configured per-zone switch. A new `effective_enabled` field reports group gating, so Home Assistant zone switches/climate entities no longer bounce back to Off solely because a climate group is disabled.
- Home Assistant zone controls use a short pending-state guard, matching the existing physical-device anti-bounce protection.
- Every zone now exposes a **Work profile** select and the zone `climate` entity supports preset modes: Auto schedule, Comfort, Sleep and Away.
## 0.7.8 Zone status wording and unit power ## 0.7.8 Zone status wording and unit power
- Zone runtime status now says **Zapotrzebowanie aktywne** when the thermostat is requesting work and **Cel osiągnięty** when the target/hysteresis condition is satisfied; **Komfort** remains only the preset name. Paused control is shown as **Oczekiwanie**, and missing control temperature as **Brak pomiaru**. - Zone runtime status now says **Zapotrzebowanie aktywne** when the thermostat is requesting work and **Cel osiągnięty** when the target/hysteresis condition is satisfied; **Komfort** remains only the preset name. Paused control is shown as **Oczekiwanie**, and missing control temperature as **Brak pomiaru**.
+1 -1
View File
@@ -211,7 +211,7 @@ When cooling is driven by a valid per-zone Home Assistant room sensor (`external
On process startup the thermostat/schedule/automation engine stays command-passive until one full live poll of all enabled GREE units completes. Persisted device/zone/group state is still available immediately for the UI, but it is not used to emit climate commands before the physical state has been synchronized. 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. `GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode, uniform house preset (or `null` for mixed zone profiles), whole-house master-power state, strategy, each zone's configured `enabled` state, group-gated `effective_enabled` state, effective `mode`, `configured_mode`, `inherit_house_mode`, resolved `preset`, explicit `preset_override` (`null` = Auto schedule), current/target temperatures, physical `device_setpoint`, current schedule, manual schedule override expiry, physical/manual device override state and expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. During physical/manual takeover, `target_temperature` remains the thermostat profile/schedule target while `device_setpoint` reports the actual GREE target; automation remains paused until resumed or another explicit zone control is sent.
```bash ```bash
curl "$BASE/api/control-plan" -H "$AUTH" curl "$BASE/api/control-plan" -H "$AUTH"
+3 -1
View File
@@ -122,6 +122,8 @@ The recommended `combined` strategy keeps the GREE sensor as the primary input a
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility. Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group.
From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning. From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created. When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
@@ -153,4 +155,4 @@ After upgrading the custom integration, restart Home Assistant or reload **Setti
## Command state stability (0.7.6) ## 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. 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. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
@@ -265,8 +265,10 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
_attr_max_temp = 30.0 _attr_max_temp = 30.0
_attr_target_temperature_step = 0.5 _attr_target_temperature_step = 0.5
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT] _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
_attr_preset_modes = ["auto", "comfort", "sleep", "away"]
_attr_supported_features = ( _attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_OFF
) )
@@ -317,15 +319,21 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
return HVACMode.AUTO return HVACMode.AUTO
return HVACMode.HEAT if str(zone.get("configured_mode", zone.get("mode", "cool"))) == "heat" else HVACMode.COOL return HVACMode.HEAT if str(zone.get("configured_mode", zone.get("mode", "cool"))) == "heat" else HVACMode.COOL
@property
def preset_mode(self) -> str:
return str(self._zone.get("preset_override") or "auto")
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone zone = self._zone
return { return {
"controller_zone_id": self._zone_id, "controller_zone_id": self._zone_id,
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"effective_mode": zone.get("mode"), "effective_mode": zone.get("mode"),
"follows_house_mode": bool(zone.get("inherit_house_mode", True)), "follows_house_mode": bool(zone.get("inherit_house_mode", True)),
"auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode", "auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode",
"preset": zone.get("preset"), "active_preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"demand": bool(zone.get("demand", False)), "demand": bool(zone.get("demand", False)),
"device_setpoint": zone.get("device_setpoint"), "device_setpoint": zone.get("device_setpoint"),
"control_source": zone.get("control_source"), "control_source": zone.get("control_source"),
@@ -343,21 +351,22 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
payload = {"enabled": True, "mode": "heat"} payload = {"enabled": True, "mode": "heat"}
else: else:
return return
await self.coordinator.client.zone_control(self._zone_id, payload) await self.coordinator.async_zone_control(self._zone_id, payload)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE) temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None: if temperature is None:
return return
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(temperature)}) await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(temperature)})
await self.coordinator.async_request_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode not in self._attr_preset_modes:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True}) await self.coordinator.async_zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False}) await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
@@ -34,6 +34,9 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
# Protect a just-accepted device command from an overlapping/stale poll. # Protect a just-accepted device command from an overlapping/stale poll.
# GREE units can expose their previous status briefly after acknowledging a write. # GREE units can expose their previous status briefly after acknowledging a write.
self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {} self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {}
# Zone switches/climate/profile controls need the same protection. A coordinator refresh
# that started before the POST must not make an accepted zone action visibly bounce back.
self._pending_zone_controls: dict[str, tuple[float, dict[str, Any]]] = {}
async def _async_update_data(self) -> dict[str, dict]: async def _async_update_data(self) -> dict[str, dict]:
try: try:
@@ -44,6 +47,7 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
) )
except GreeControllerApiError as err: except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err raise UpdateFailed(str(err)) from err
self._overlay_pending_zone_controls(plan)
self.plan = plan self.plan = plan
self.groups = {str(group["id"]): group for group in groups if group.get("id")} self.groups = {str(group["id"]): group for group in groups if group.get("id")}
device_map = {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")}
@@ -77,6 +81,68 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
if device is not None: if device is not None:
device.update(expected) device.update(expected)
@staticmethod
def _normalized_zone_control(payload: dict[str, Any]) -> dict[str, Any]:
"""Map zone-control API fields to the Home Assistant control-plan representation."""
expected: dict[str, Any] = {}
if "enabled" in payload:
expected["enabled"] = bool(payload["enabled"])
if "mode" in payload:
mode = str(payload["mode"])
if mode in {"house", "auto"}:
expected["inherit_house_mode"] = True
elif mode in {"cool", "heat"}:
expected["inherit_house_mode"] = False
expected["configured_mode"] = mode
expected["mode"] = mode
if "preset" in payload:
preset = str(payload["preset"])
expected["preset_override"] = None if preset == "auto" else preset
if preset != "auto":
expected["preset"] = preset
if "setpoint" in payload:
value = max(8.0, min(30.0, float(payload["setpoint"])))
expected["target_temperature"] = round(value * 2.0) / 2.0
if payload.get("clear_override"):
expected["preset_override"] = None
return expected
def _overlay_pending_zone_controls(self, plan: dict[str, Any]) -> None:
"""Overlay accepted zone controls onto stale/in-flight control-plan reads."""
now = asyncio.get_running_loop().time()
zones = plan.get("zones", [])
for zone_id, (deadline, expected) in list(self._pending_zone_controls.items()):
if now >= deadline:
self._pending_zone_controls.pop(zone_id, None)
continue
for zone in zones:
if str(zone.get("zone_id")) == zone_id:
zone.update(expected)
break
async def async_zone_control(self, zone_id: str, payload: dict[str, Any]) -> None:
"""Send a thermostat-zone command with a short optimistic anti-bounce guard."""
expected = self._normalized_zone_control(payload)
loop = asyncio.get_running_loop()
self._pending_zone_controls[zone_id] = (loop.time() + 5.0, expected)
if self.plan:
optimistic_plan = dict(self.plan)
optimistic_plan["zones"] = [dict(zone) for zone in self.plan.get("zones", [])]
self._overlay_pending_zone_controls(optimistic_plan)
self.plan = optimistic_plan
self.async_set_updated_data(dict(self.data or {}))
try:
await self.client.zone_control(zone_id, payload)
except GreeControllerApiError:
self._pending_zone_controls.pop(zone_id, None)
await self.async_request_refresh()
raise
self._pending_zone_controls[zone_id] = (loop.time() + 3.0, expected)
await self.async_request_refresh()
async def async_device_command(self, device_id: str, payload: dict[str, Any]) -> None: 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.""" """Send a physical-unit command while keeping HA state monotonic during settling."""
expected = self._normalized_device_command(payload) expected = self._normalized_device_command(payload)
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.7.8", "version": "0.7.9",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
@@ -76,5 +76,4 @@ class GreeControllerZoneTargetNumber(CoordinatorEntity[GreeControllerCoordinator
) )
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(value)}) await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(value)})
await self.coordinator.async_request_refresh()
@@ -1,4 +1,4 @@
"""Whole-house and climate-group thermostat selectors for GREE Controller.""" """Whole-house, zone and climate-group thermostat selectors for GREE Controller."""
from __future__ import annotations from __future__ import annotations
@@ -45,12 +45,16 @@ async def async_setup_entry(
entry: ConfigEntry, entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Create whole-house thermostat mode and work-profile selectors.""" """Create whole-house, zone and climate-group thermostat selectors."""
runtime: GreeControllerRuntimeData = entry.runtime_data runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SelectEntity] = [ entities: list[SelectEntity] = [
GreeControllerHouseModeSelect(runtime.coordinator), GreeControllerHouseModeSelect(runtime.coordinator),
GreeControllerHousePresetSelect(runtime.coordinator), GreeControllerHousePresetSelect(runtime.coordinator),
] ]
for zone in runtime.coordinator.plan.get("zones", []):
zone_id = str(zone.get("zone_id") or "").strip()
if zone_id:
entities.append(GreeControllerZonePresetSelect(runtime.coordinator, zone_id))
for group_id in runtime.coordinator.groups: for group_id in runtime.coordinator.groups:
entities.append(GreeControllerGroupModeSelect(runtime.coordinator, group_id)) entities.append(GreeControllerGroupModeSelect(runtime.coordinator, group_id))
entities.append(GreeControllerGroupPresetSelect(runtime.coordinator, group_id)) entities.append(GreeControllerGroupPresetSelect(runtime.coordinator, group_id))
@@ -116,6 +120,56 @@ class GreeControllerHousePresetSelect(_GreeControllerHouseSelect):
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
class GreeControllerZonePresetSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Select the temporary work profile for one thermostat zone."""
_attr_has_entity_name = True
_attr_name = "Work profile"
_attr_options = list(HOUSE_PRESET_VALUES)
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-work-profile"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def current_option(self) -> str | None:
value = str(self._zone.get("preset_override") or "auto")
return HOUSE_PRESET_LABELS.get(value)
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
return {"active_profile": self._zone.get("preset")}
async def async_select_option(self, option: str) -> None:
value = HOUSE_PRESET_VALUES.get(option)
if value is None:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": value})
class _GreeControllerGroupSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity): class _GreeControllerGroupSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Base class for climate-group selectors.""" """Base class for climate-group selectors."""
@@ -65,7 +65,8 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
"work_profile": plan.get("house_preset"), "work_profile": plan.get("house_preset"),
"master_power": bool(plan.get("house_power", False)), "master_power": bool(plan.get("house_power", False)),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones), "enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones), "effective_enabled_zones": sum(bool(zone.get("effective_enabled", zone.get("enabled"))) for zone in zones),
"demanding_zones": sum(bool(zone.get("demand")) for zone in zones),
"groups": len(groups), "groups": len(groups),
"enabled_groups": sum(bool(group.get("power_enabled")) for group in groups), "enabled_groups": sum(bool(group.get("power_enabled")) for group in groups),
"demanding_groups": sum(int(group.get("demanding_zones") or 0) > 0 for group in groups), "demanding_groups": sum(int(group.get("demanding_zones") or 0) > 0 for group in groups),
@@ -102,6 +103,8 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator],
zone = self._zone zone = self._zone
if not zone.get("enabled", False): if not zone.get("enabled", False):
return "disabled" return "disabled"
if not zone.get("effective_enabled", True):
return "blocked"
return "requesting" if zone.get("demand", False) else "satisfied" return "requesting" if zone.get("demand", False) else "satisfied"
@property @property
@@ -122,8 +125,11 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator],
"zone_id": self._zone_id, "zone_id": self._zone_id,
"device_id": zone.get("device_id"), "device_id": zone.get("device_id"),
"device_name": zone.get("device_name"), "device_name": zone.get("device_name"),
"enabled": bool(zone.get("enabled", False)),
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"mode": zone.get("mode"), "mode": zone.get("mode"),
"preset": zone.get("preset"), "preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"current_temperature": zone.get("current_temperature"), "current_temperature": zone.get("current_temperature"),
"target_temperature": zone.get("target_temperature"), "target_temperature": zone.get("target_temperature"),
"device_setpoint": zone.get("device_setpoint"), "device_setpoint": zone.get("device_setpoint"),
@@ -170,12 +170,10 @@ class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinato
) )
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True}) await self.coordinator.async_zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False}) await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity): class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Optional GREE unit feature exposed only when the controller detected it.""" """Optional GREE unit feature exposed only when the controller detected it."""
+10 -1
View File
@@ -654,11 +654,18 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
} }
if let Some(value) = patch.enabled { zone.enabled = value; } if let Some(value) = patch.enabled { zone.enabled = value; }
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
let house_mode = state.settings.read().await.house_mode.clone();
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
state.db.save_zone(&zone)?; state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if was_enabled && !zone.enabled { if was_enabled && !zone.enabled {
power_off_zone_device(&state, &zone, "zone.quick_disabled").await; power_off_zone_device(&state, &zone, "zone.quick_disabled").await;
} else {
// Do not force users to wait for the fixed zone interval after selecting a profile,
// changing the target/mode or re-enabling a thermostat. The regulator still owns the
// physical command and therefore keeps all group/master/manual-override safeguards.
state.wake_zone_control();
} }
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({ state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode, "zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
@@ -913,6 +920,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"device_id": zone.device_id, "device_id": zone.device_id,
"device_name": zone.device_name, "device_name": zone.device_name,
"enabled": zone.enabled, "enabled": zone.enabled,
"effective_enabled": zone.effective_enabled,
"mode": zone.mode, "mode": zone.mode,
"configured_mode": zone.configured_mode, "configured_mode": zone.configured_mode,
"inherit_house_mode": zone.inherit_house_mode, "inherit_house_mode": zone.inherit_house_mode,
@@ -938,7 +946,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"house_mode": settings.house_mode, "house_mode": settings.house_mode,
"zone_count": members.len(), "zone_count": members.len(),
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(), "enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
"active_zones": planned_members.iter().filter(|zone| zone.enabled).count(), "active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(),
"demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(), "demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(),
"device_count": member_device_ids.len(), "device_count": member_device_ids.len(),
"online_devices": online_devices, "online_devices": online_devices,
@@ -1124,6 +1132,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
} }
let failed = command_all_enabled_devices_power(&state, true, "house_preset").await?; let failed = command_all_enabled_devices_power(&state, true, "house_preset").await?;
state.wake_zone_control();
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
"preset": input.preset, "preset": input.preset,
+53 -7
View File
@@ -47,7 +47,10 @@ pub fn start(state: AppState) {
tracing::error!(error=?err, "automation cycle failed"); tracing::error!(error=?err, "automation cycle failed");
} }
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
sleep(Duration::from_secs(seconds)).await; tokio::select! {
_ = sleep(Duration::from_secs(seconds)) => {},
_ = control_state.zone_control_wakeup.notified() => {},
}
} }
}); });
@@ -728,6 +731,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
} }
} }
if desired_power && should_command_power {
state.wake_zone_control();
}
state.log("info", source, &format!("Updated group {}", group.name), json!({ state.log("info", source, &format!("Updated group {}", group.name), json!({
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled, "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
@@ -1023,8 +1029,19 @@ async fn control_zones(state: &AppState) -> Result<()> {
// automation control. Continue sensor/history updates, but reflect the unit's real state // 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. // instead of sending corrective frames that would fight the person holding the remote.
if zone.device_manual_override { if zone.device_manual_override {
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
// selected profile/target. Keep the intended target visible and report the physical
// unit target separately through device_setpoint. This makes Resume/Profile actions
// deterministic and avoids a standby device target (for example 25 C) masquerading
// as the zone's Sleep/Comfort target.
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
// Keep effective_mode's existing meaning during takeover: it reflects the physical
// unit, while effective_setpoint above remains the thermostat intent.
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; 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.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.demand = false; zone.demand = false;
zone.demand_since = None; zone.demand_since = None;
@@ -1529,6 +1546,18 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
(preset, zone.manual_setpoint.unwrap_or(base_target)) (preset, zone.manual_setpoint.unwrap_or(base_target))
} }
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
zone.effective_mode = configured_mode.to_string();
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode };
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
zone.active_preset = preset;
if !zone.device_manual_override {
zone.effective_setpoint = Some(target);
}
}
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> { fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
schedules.iter() schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
@@ -1677,20 +1706,23 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
event.label = format!("{}: {}", zone.name, event.label); event.label = format!("{}: {}", zone.name, event.label);
house_events.push(event); house_events.push(event);
} }
let effective_enabled = zone.enabled && (!blocked_by_group || zone.device_manual_override);
zones_out.push(ZoneControlPlan { zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(), zone_id: zone.id.clone(),
zone_name: zone.name.clone(), zone_name: zone.name.clone(),
device_id: zone.device_id.clone(), device_id: zone.device_id.clone(),
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()), device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled && (!blocked_by_group || zone.device_manual_override), enabled: zone.enabled,
effective_enabled,
mode: effective_mode.to_string(), mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(), configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode, inherit_house_mode: zone.inherit_house_mode,
preset: if zone.active_preset.is_empty() { resolved_preset } else { zone.active_preset.clone() }, preset: resolved_preset,
preset_override: zone.manual_preset.clone(),
current_temperature: zone.current_temperature, current_temperature: zone.current_temperature,
target_temperature: if zone.device_manual_override { target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
device.filter(|item| item.power).map(|item| item.target_temperature).or(Some(resolved_target)) // A remote/manual takeover may leave a physical standby target persisted in the
} else if !zone.enabled || effective_mode == "off" { // zone runtime snapshot. Never publish that value as the thermostat target.
Some(resolved_target) Some(resolved_target)
} else { } else {
zone.effective_setpoint.or(Some(resolved_target)) zone.effective_setpoint.or(Some(resolved_target))
@@ -2231,6 +2263,20 @@ mod tests {
assert_eq!(target, 22.5); assert_eq!(target, 22.5);
} }
#[test]
fn runtime_target_refresh_applies_manual_profile_immediately() {
let mut zone = test_zone("device");
zone.inherit_house_mode = false;
zone.mode = "cool".into();
zone.manual_preset = Some("sleep".into());
zone.active_preset = "comfort".into();
zone.effective_setpoint = Some(25.0);
refresh_zone_runtime_target(&mut zone, &[], "cool");
assert_eq!(zone.active_preset, "sleep");
assert_eq!(zone.effective_setpoint, Some(24.5));
assert_eq!(zone.effective_mode, "cool");
}
#[test] #[test]
fn legacy_zone_keeps_old_comfort_setpoint() { fn legacy_zone_keeps_old_comfort_setpoint() {
let mut zone = test_zone("device"); let mut zone = test_zone("device");
+2 -1
View File
@@ -18,7 +18,7 @@ use db::Db;
use models::Device; use models::Device;
use protocol::GreeClient; use protocol::GreeClient;
use state::AppState; use state::AppState;
use tokio::{net::TcpListener, signal, sync::{broadcast, RwLock}}; use tokio::{net::TcpListener, signal, sync::{broadcast, Notify, RwLock}};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main] #[tokio::main]
@@ -67,6 +67,7 @@ async fn main() -> Result<()> {
outdoor_temperature: Arc::new(RwLock::new(None)), outdoor_temperature: Arc::new(RwLock::new(None)),
debug_gree_frames, debug_gree_frames,
initial_device_sync_complete: Arc::new(AtomicBool::new(false)), initial_device_sync_complete: Arc::new(AtomicBool::new(false)),
zone_control_wakeup: Arc::new(Notify::new()),
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
started: Instant::now(), started: Instant::now(),
}; };
+6
View File
@@ -708,13 +708,19 @@ pub struct ZoneControlPlan {
pub zone_name: String, pub zone_name: String,
pub device_id: String, pub device_id: String,
pub device_name: String, pub device_name: String,
/// Configured per-zone enable switch. Group/master gates are reported separately.
pub enabled: bool, pub enabled: bool,
/// True when the configured zone is not currently blocked by a disabled climate group.
pub effective_enabled: bool,
/// Effective mode currently used by the controller. /// Effective mode currently used by the controller.
pub mode: String, pub mode: String,
/// Configured zone mode before house-mode inheritance is resolved. /// Configured zone mode before house-mode inheritance is resolved.
pub configured_mode: String, pub configured_mode: String,
pub inherit_house_mode: bool, pub inherit_house_mode: bool,
/// Profile resolved from a manual override or the active schedule.
pub preset: String, pub preset: String,
/// Explicit per-zone profile override; None means Auto schedule.
pub preset_override: Option<String>,
pub current_temperature: Option<f64>, pub current_temperature: Option<f64>,
pub target_temperature: Option<f64>, pub target_temperature: Option<f64>,
pub device_setpoint: Option<f64>, pub device_setpoint: Option<f64>,
+7 -1
View File
@@ -1,7 +1,7 @@
use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant}; use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant};
use chrono::Utc; use chrono::Utc;
use serde_json::Value; use serde_json::Value;
use tokio::sync::{broadcast, Mutex, OwnedMutexGuard, RwLock}; use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock};
use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient}; use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient};
#[derive(Clone)] #[derive(Clone)]
@@ -17,6 +17,8 @@ pub struct AppState {
/// Thermostat/automation control stays passive until every enabled device has had /// Thermostat/automation control stays passive until every enabled device has had
/// one startup poll, preventing stale persisted device state from causing restart commands. /// one startup poll, preventing stale persisted device state from causing restart commands.
pub initial_device_sync_complete: Arc<AtomicBool>, pub initial_device_sync_complete: Arc<AtomicBool>,
/// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval.
pub zone_control_wakeup: Arc<Notify>,
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>, pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub started: Instant, pub started: Instant,
} }
@@ -30,6 +32,10 @@ impl AppState {
lock.lock_owned().await lock.lock_owned().await
} }
pub fn wake_zone_control(&self) {
self.zone_control_wakeup.notify_one();
}
pub fn broadcast(&self, event: impl Into<String>, data: Value) { pub fn broadcast(&self, event: impl Into<String>, data: Value) {
let _ = self.events.send(ApiEvent { let _ = self.events.send(ApiEvent {
event: event.into(), event: event.into(),
+13 -7
View File
@@ -278,6 +278,11 @@ function automationTriggerLabel(item) {
return tr(key, {temperature: fmtTemp(item.threshold)}); return tr(key, {temperature: fmtTemp(item.threshold)});
} }
function planZoneEffectiveEnabled(zone) {
if (!zone) return undefined;
return zone.effective_enabled ?? zone.enabled ?? false;
}
function renderControlPlan() { function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return; const host = $('#controlPlan'); if (!host) return;
const plan = app.controlPlan; const plan = app.controlPlan;
@@ -288,14 +293,14 @@ function renderControlPlan() {
const allZones = plan.zones || []; const allZones = plan.zones || [];
const houseEvents = (plan.next_events || []).slice(0, 3); const houseEvents = (plan.next_events || []).slice(0, 3);
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:allZones.filter(zone=>zone.enabled).length, demand:allZones.filter(zone=>zone.enabled && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`; const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
const groupCards = (app.groups || []).map(group => { const groupCards = (app.groups || []).map(group => {
const memberIds = new Set(group.zone_ids || []); const memberIds = new Set(group.zone_ids || []);
const members = allZones.filter(zone => memberIds.has(zone.zone_id)); const members = allZones.filter(zone => memberIds.has(zone.zone_id));
const state = groupState(group); const state = groupState(group);
const powerEnabled = group.power_enabled !== false; const powerEnabled = group.power_enabled !== false;
const demand = members.filter(zone => zone.enabled && zone.demand).length; const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length;
const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode); const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode);
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset); const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`}))) const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`})))
@@ -315,7 +320,7 @@ function renderControlPlan() {
const groupNames = zoneGroups.get(zone.zone_id) || []; const groupNames = zoneGroups.get(zone.zone_id) || [];
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice')); const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off'); const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off');
return `<article class="panel plan-card plan-zone ${zone.enabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (zone.enabled && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`; return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
}).join(''); }).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled); const rules = (plan.rules || []).filter(rule => rule.enabled);
@@ -383,7 +388,7 @@ function buildZoneSimulation(zone, planZone) {
const half = hysteresis / 2; const half = hysteresis / 2;
let status = 'waiting'; let status = 'waiting';
let demand = false; let demand = false;
if (!zone?.enabled || planZone?.enabled === false) status = 'disabled'; if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled';
else if (mode === 'off') status = 'off'; else if (mode === 'off') status = 'off';
else if (current == null || target == null) status = 'waiting'; else if (current == null || target == null) status = 'waiting';
else { else {
@@ -419,7 +424,7 @@ function buildZoneSimulation(zone, planZone) {
? tr('simulation.quietIfSupported') ? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual')); : (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true; const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZone?.enabled === false const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false
? tr('simulation.reasonDisabled') ? tr('simulation.reasonDisabled')
: mode === 'off' : mode === 'off'
? tr('simulation.reasonHouseOff') ? tr('simulation.reasonHouseOff')
@@ -701,6 +706,7 @@ function zoneCard(zone, detailed = true) {
const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature; const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature;
const target = Number(zone.effective_setpoint ?? zone.setpoint); const target = Number(zone.effective_setpoint ?? zone.setpoint);
const manual = zone.manual_preset || 'auto'; const manual = zone.manual_preset || 'auto';
const displayPreset = zone.manual_preset || zone.active_preset || 'comfort';
const mode = zone.inherit_house_mode ? 'house' : zone.mode; 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 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 hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
@@ -726,7 +732,7 @@ function zoneCard(zone, detailed = true) {
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div> <div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<div class="zone-config-status"> <div class="zone-config-status">
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b></b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : ''}</strong></div></div> <div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b></b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : ''}</strong></div></div>
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(zone.active_preset))}</span><small>${esc(override)}</small></div> <div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small></div>
</div> </div>
<div class="zone-config-grid"> <div class="zone-config-grid">
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div> <div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
@@ -741,7 +747,7 @@ function zoneCard(zone, detailed = true) {
} }
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}"> return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '' : ''}</span>${esc(state)}</button></div> <div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '' : ''}</span>${esc(state)}</button></div>
${manualUnitPower} ${manualUnitPower}
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div> <div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
<div class="preset-row quick-control-row quick-control-row-4"> <div class="preset-row quick-control-row quick-control-row-4">
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v078-zone-status-unit-power'; const CACHE = 'gree-controller-v079-zone-profile-control';
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/'; 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')]; 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')];