v0.9.7
This commit is contained in:
+26
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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')];
|
||||||
|
|||||||
Reference in New Issue
Block a user