v0.7.10
This commit is contained in:
+17
-5
@@ -1,8 +1,8 @@
|
|||||||
# GREE Controller v0.7.9 - build and validation report
|
# GREE Controller v0.7.10 - build and validation report
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
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.
|
Version 0.7.10 includes the previous climate-group, whole-house and zone-profile fixes plus a strict separation between local quick-thermostat ownership and true physical/manual-device takeover. Global controls are consolidated, group control stays immediately below them, and lower-priority dashboard sections are collapsed on demand.
|
||||||
|
|
||||||
## Implemented changes
|
## Implemented changes
|
||||||
|
|
||||||
@@ -395,13 +395,25 @@ Rust compiler note: this packaging environment still does not provide `cargo`, `
|
|||||||
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.
|
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
|
## 2026-08-27 v0.7.10 local quick thermostat and false takeover fix
|
||||||
|
|
||||||
|
- Added persisted `local_thermostat_power` ownership with three distinct states: normal group/schedule ownership (`null`), locally forced thermostat ON (`true`) and locally forced thermostat OFF (`false`).
|
||||||
|
- Dashboard quick ON/OFF and the legacy zone manual-power route now use local thermostat semantics; they never call the direct/manual-device command path.
|
||||||
|
- A locally active thermostat bypasses disabled climate-group power gates while retaining the normal sensor, hysteresis, profile/target, Smart Fan and GREE setpoint-modulation logic.
|
||||||
|
- **Resume automation** for a local thermostat clears local ownership and returns the zone to group/schedule control. The existing device takeover Resume action remains separate.
|
||||||
|
- Direct device commands and genuine remote/pilot changes still create `device_manual_override`; local thermostat ownership does not.
|
||||||
|
- Added a bounded pending controller-command record for GREE climate fields when post-command status has not settled. A later poll matching that expected state is no longer misclassified as remote control, fixing false **Manual control / remote** after group ON and similar controller-originated transitions.
|
||||||
|
- Direct device automations are suppressed while local thermostat ownership is active so they cannot fight the local regulator.
|
||||||
|
- Home Assistant zone climate ON/OFF now maps to local thermostat power; the separate Enabled switch remains the technical zone enable.
|
||||||
|
- Rotated the PWA cache key and advanced package/Home Assistant metadata to `0.7.10`.
|
||||||
|
|
||||||
|
## Final validation for v0.7.10
|
||||||
|
|
||||||
- `node --check` passed for all Web UI JavaScript files.
|
- `node --check` passed for all Web UI JavaScript files.
|
||||||
- Home Assistant modules, helper scripts and `make_zip.py` passed `python3 -m py_compile`.
|
- 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 JSON files parsed successfully; Polish and English language packs have identical 598-key sets.
|
||||||
- All shell scripts passed `bash -n`; `web/index.html` parsed successfully and CSS braces are balanced.
|
- 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`.
|
- Cargo package, Home Assistant manifest, README and PWA cache metadata consistently report version `0.7.10`.
|
||||||
- Rust source delimiter/static invariant checks passed, including the new regulator wake-up and control-plan fields.
|
- 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`.
|
- `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.
|
- 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.9"
|
version = "0.7.10"
|
||||||
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.9"
|
version = "0.7.10"
|
||||||
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"
|
||||||
|
|||||||
+18
-18
@@ -1,29 +1,29 @@
|
|||||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
eb1d764a4d0622db45bcdfde3e7c94075707a375ae62c3e316678fce86838551 ./BUILD_REPORT.md
|
ef3c2cdf13fdeac192c2f94fb321af3dc60ad93d97266c00cf2b8021ed0f211c ./BUILD_REPORT.md
|
||||||
e07c746bbf859c3a36e48d27fd381c29e2c56086fa1df68ecc30eea8a73fc335 ./Cargo.lock
|
723ce7201821a3ff3404c93430de5dc8451f0e091f39fdbe532167d9a647250a ./Cargo.lock
|
||||||
bc5a32e369cbf84dfb12faa2f0a70c53e2034ade769c0dfa88b04b645a755864 ./Cargo.toml
|
1c52ed460ad66fa818d0f405ca92ef9df1beff0c220f34c548e54d93e7e99721 ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
46b7e09ab96f428ff6d5eb8a95a340d827af7f986176ef35c5a3e3111e3bf7d3 ./README.md
|
536dec61cf1e37c98b33989db53f356895fb1c241d72eccf317e800a830fa984 ./README.md
|
||||||
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
||||||
f19897119089a4c6415bc35368eb9e5607aa7cafc6360c87812b1c662f30fadf ./docs/API.md
|
5254141d54e0fe55bb4942d9328221eeddae6d8f63d71d33c2e642647bd3cac6 ./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
|
||||||
21e899f338b5f1d8267a0096293750cf240e4b35a893c9680af56515ad9f7320 ./home-assistant/README.md
|
8cd55020fd740bc4b26ee6215218ea9c49ba37e1566e2128831c004bd35c52a2 ./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
|
||||||
8c10cb10b31357d9eee371e1d1c078b15fb27b2c2cdbdbac35b049762737de22 ./home-assistant/custom_components/gree_controller/climate.py
|
15b7430190bec9b82690d4073b1371d78746d01931874d45a05858ea4334cc37 ./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
|
||||||
923f68c5ee8def4443749d891599d7176e1a38c5a99552ccbe3502e34b425925 ./home-assistant/custom_components/gree_controller/coordinator.py
|
b065b61ef1adafe96aae3e47bff3e8b0bba57796fdfac1503669046beb1c0157 ./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
|
||||||
67c87a0dbc7a3ed7ad617ddbd6996de7b6c4a40653745ad310ec0cfe36860e45 ./home-assistant/custom_components/gree_controller/manifest.json
|
dcfa12c94cfb939078c13658162c2f1e4bf7ad2f29d790524e72a1d3c4a4f3d7 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||||
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
||||||
1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py
|
1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||||
@@ -32,8 +32,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assista
|
|||||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
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
|
||||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||||
62dcacc6f168edfaa861d8c5ff32883f35bcf0a045f6ee3899588a748427ce63 ./lang/en.json
|
cc404db7cac21c39c0c5d61ead78a36835beb0fa599db1da02df1b69044dbc3b ./lang/en.json
|
||||||
79cda4f5b28ebf760e82b05aeef11a79c0129e895c4c1588344561d1a948bce3 ./lang/pl.json
|
ae856c40dc495337f649f347cfbf1842f8a25ca803c3413d9ab2b6c181427a2a ./lang/pl.json
|
||||||
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||||
@@ -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
|
||||||
1f131ce502b6e11057e0522d44fec484ac0af769a6af6c4a0c7fa420567faad9 ./src/api.rs
|
7c4bf023ad1df88abdbc98eb0ff60f984396359f6d8320b8817da5fe832e76a6 ./src/api.rs
|
||||||
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
|
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
|
||||||
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
|
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
|
||||||
780ba1214043b2894232eebebc9c89dcbfd33cd4f6d263baacb4c42bbcc6692e ./src/engine.rs
|
5da9a1369af514300911dc59c8f53a5d254a6e3622d6a689786b3ca5bfb1fa40 ./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
|
||||||
708353b36fd1604c10fd81898c1b7abf463ee1271544e27f38a4b51291601b36 ./src/main.rs
|
4ae0cc270938a9626148490c04c69b01cda75c30547a63a6a1b377b2109a0f9c ./src/main.rs
|
||||||
20268f5bc184eee0ce7272ffc52c07ff5909eea2f866f798a20f4a4620fbac3c ./src/models.rs
|
fccf60732e66ff7d185ee8324196e9cea29a220a610a551b3b830799ef4cdbc4 ./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
|
||||||
115a386a76db7e6182bae58067d43c11ef5bf9aae6fe9ac149f13c7191a5812a ./src/state.rs
|
6081635986e1ce39a0e195c3976ae30a033454fe90ec80bcbdf05fc26bf7087f ./src/state.rs
|
||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
59aa9e04b0251775ee6460e04989720866756fde7cc1378fa0f09fa4b6d00be1 ./web/app.js
|
f7c44789b68f6f49e8409b3867f410f2ed39cb59d0bbffceec7fd9dad2bd5f71 ./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
|
||||||
3ec8109e706047d95387deb11af80164e97d5a6f0ad93cc70a1a71d6eae194a0 ./web/sw.js
|
38cec1ebc782749c4838e1bd73cd371fb57a99310df793c52a69adad9f36b5c7 ./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.9**.
|
Current version: **0.7.10**.
|
||||||
|
|
||||||
## 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.9.zip
|
unzip gree-controller-v0.7.10.zip
|
||||||
cd gree-controller
|
cd gree-controller
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/dev.sh
|
./scripts/dev.sh
|
||||||
@@ -218,7 +218,7 @@ For combined control, `max_sensor_difference` protects against an obviously inco
|
|||||||
|
|
||||||
The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload.
|
The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload.
|
||||||
|
|
||||||
Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep / Away**, **Global / Heat / Cool**, and per-unit **ON/OFF** are available without opening the edit dialog. The per-unit power buttons intentionally behave like the physical remote: they affect only that unit, can bypass a disabled group without enabling the whole group, and create the normal manual-device takeover. Temporary thermostat overrides automatically end at the next schedule boundary; if a zone has no future schedule transition, the override stays active until it is cleared. The zone engine, not the browser, owns device commands so schedules, sensors and manual overrides cannot fight each other.
|
Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep / Away**, **Global / Heat / Cool**, and local thermostat **ON/OFF** are available without opening the edit dialog. Local ON/OFF is thermostat ownership, not direct/pilot control: a locally started zone can bypass a disabled climate-group power gate while still using its room sensor, hysteresis, selected target/profile, Smart Fan and GREE setpoint modulation. **Resume automation** removes that local power override and returns the zone to group/schedule ownership; if its group is off, the unit then stays off. Only a real physical/remote change or the technical Devices command path creates `device_manual_override`. Controller-originated GREE transitions are tracked through the short device settling window so group/thermostat commands are not misclassified as remote takeover. Temporary thermostat overrides otherwise end at the next schedule boundary; when a local thermostat is explicitly active, its selected manual target/profile is retained until Auto/Resume is chosen.
|
||||||
|
|
||||||
## Smart thermostat and schedules
|
## Smart thermostat and schedules
|
||||||
|
|
||||||
@@ -477,6 +477,15 @@ 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.10 Local quick-thermostat ownership
|
||||||
|
|
||||||
|
- Quick thermostat ON/OFF now controls a dedicated local thermostat state instead of using remote/manual-device semantics.
|
||||||
|
- A locally started zone bypasses its disabled group power gate but keeps full thermostat regulation: room sensor, hysteresis, profile/target, Smart Fan and GREE setpoint modulation.
|
||||||
|
- **Resume automation** clears local thermostat ownership and returns the zone to group/schedule control.
|
||||||
|
- Physical remote/direct device changes remain the only source of `device_manual_override`; explicit thermostat actions can take ownership back.
|
||||||
|
- Controller-originated GREE changes that appear after the device settling window are correlated with the pending controller command, preventing group ON and thermostat commands from falsely showing **Manual control / remote**.
|
||||||
|
- Home Assistant zone climate ON/OFF now uses the same local thermostat power semantics instead of technically disabling the zone.
|
||||||
|
|
||||||
## 0.7.9 Zone profile control and Home Assistant stability
|
## 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.
|
- Quick thermostat profile/mode/target/enable actions now wake the thermostat regulator immediately instead of waiting for the next fixed zone interval.
|
||||||
|
|||||||
+14
-8
@@ -81,7 +81,7 @@ curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/j
|
|||||||
-d '{"preset":"sleep"}'
|
-d '{"preset":"sleep"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `POST /api/house/power` controls a separate whole-house master power state. With master power off, zones and controller automations cannot restart units; the selected house thermostat mode is preserved. This is independent from house mode `off`, which only means no house-level thermostat control and allows direct/manual device operation. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on, all group power gates are enabled and only devices assigned to enabled thermostat zones that are not in manual-device override are powered on. `power:false` is authoritative: it clears manual-device overrides, disables every group and powers off every enabled physical device. Disabled zones otherwise remain outside house/group/automation control and their underlying unit can be operated manually through technical device control or its physical remote. Choosing house mode `off` does not change master power.
|
House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `POST /api/house/power` controls a separate whole-house master power state. With master power off, zones and controller automations cannot restart units; the selected house thermostat mode is preserved. This is independent from house mode `off`, which only means no house-level thermostat control and allows direct/manual device operation. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on, all group power gates are enabled and only devices assigned to enabled thermostat zones that are not in manual-device override are powered on. `power:false` is authoritative: it clears manual-device overrides and local quick-thermostat power ownership, disables every group and powers off every enabled physical device. Disabled zones otherwise remain outside house/group/automation control and their underlying unit can be operated manually through technical device control or its physical remote. Choosing house mode `off` does not change master power.
|
||||||
|
|
||||||
A zone stores separate profile temperatures for both seasons:
|
A zone stores separate profile temperatures for both seasons:
|
||||||
|
|
||||||
@@ -125,26 +125,32 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl
|
|||||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||||
-d '{"setpoint":22.5}'
|
-d '{"setpoint":22.5}'
|
||||||
|
|
||||||
# Manual power for only this zone's physical unit. This behaves like a remote and
|
# Start this zone as a local thermostat. This does not enable its climate group,
|
||||||
# does not enable the containing climate group.
|
# but the full thermostat logic (sensor/hysteresis/profile/GREE modulation) remains active.
|
||||||
curl -X POST "$BASE/api/zones/ZONE_ID/manual-power" -H "$AUTH" -H 'Content-Type: application/json' \
|
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||||
-d '{"power":true}'
|
-d '{"power":true}'
|
||||||
|
|
||||||
|
# Return local thermostat power ownership to the group/schedule.
|
||||||
|
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"clear_local_thermostat_override":true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow global mode** in the UI and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones.
|
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow global mode** in the UI and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones.
|
||||||
|
|
||||||
|
`power:true/false` on `/api/zones/{id}/control` is a local quick-thermostat power override, not a physical/manual command. `true` lets that zone run through the full thermostat even when its climate group is off; `false` keeps only that zone locally off. `clear_local_thermostat_override:true` returns power ownership to the normal group/schedule path. The legacy `/api/zones/{id}/manual-power` route is retained for compatibility but now has the same local-thermostat semantics. Direct/pilot-style control remains `/api/devices/{id}/command`.
|
||||||
|
|
||||||
### Physical/manual device takeover
|
### Physical/manual device takeover
|
||||||
|
|
||||||
The poller compares climate-relevant unit state with the last controller-known state. An external change of power, HVAC mode, target temperature or fan speed (for example from the IR remote) sets `device_manual_override=true` on the assigned zone. The zone continues sensor/history updates but thermostat modulation, schedules, groups and direct-device automations stop issuing corrective climate commands. The controller stores the pre-takeover climate state; if the user later returns the unit to that operational state, the takeover is cleared automatically and the stale **Resume automation** prompt disappears. When the pre-takeover state was OFF, switching the unit OFF again is sufficient even if the remote kept a different dormant target/mode internally. Otherwise the override expires at the next schedule transition when one exists; without a future transition it stays active until explicitly resumed. The known GREE standby normalization from Low fan back to Auto is ignored so it does not create a false takeover.
|
The poller compares climate-relevant unit state with the last controller-known state. An external change of power, HVAC mode, target temperature or fan speed (for example from the IR remote) sets `device_manual_override=true` on the assigned zone. Controller-originated thermostat/group commands are correlated with a short-lived expected-state record when GREE status has not settled yet, so their delayed status transition is not mistaken for an external takeover. The zone continues sensor/history updates but thermostat modulation, schedules, groups and direct-device automations stop issuing corrective climate commands. The controller stores the pre-takeover climate state; if the user later returns the unit to that operational state, the takeover is cleared automatically and the stale **Resume automation** prompt disappears. When the pre-takeover state was OFF, switching the unit OFF again is sufficient even if the remote kept a different dormant target/mode internally. Otherwise the override expires at the next schedule transition when one exists; without a future transition it stays active until explicitly resumed. The known GREE standby normalization from Low fan back to Auto is ignored so it does not create a false takeover.
|
||||||
|
|
||||||
The technical `POST /api/devices/{id}/command` path uses the same takeover semantics for climate-relevant changes; light-only and unrelated feature changes do not suspend the thermostat. An explicit quick-zone action (target, mode, preset, enabled state) resumes thermostat ownership automatically. It can also be resumed directly:
|
The technical `POST /api/devices/{id}/command` path uses the same takeover semantics for climate-relevant changes; light-only and unrelated feature changes do not suspend the thermostat. Local quick-thermostat power is deliberately separate from this state. An explicit quick-zone action (local power, target, mode, preset or enabled state) resumes thermostat ownership from a physical/direct takeover automatically. It can also be resumed directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||||
-d '{"clear_device_manual_override":true}'
|
-d '{"clear_device_manual_override":true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Whole-house `power:false` is the deliberate exception and always clears the takeover and powers the unit off. Group power/mode/profile commands do not fight a zone while its manual-device override is active.
|
Whole-house `power:false` is the deliberate exception and always clears both physical/manual takeover and local quick-thermostat ownership before powering units off. Group power commands do not alter the physical unit while that zone has an explicit local thermostat power override, and group/thermostat commands do not fight a zone while its physical/manual-device override is active.
|
||||||
|
|
||||||
### Climate groups
|
### Climate groups
|
||||||
|
|
||||||
@@ -211,7 +217,7 @@ When cooling is driven by a valid per-zone Home Assistant room sensor (`external
|
|||||||
|
|
||||||
On process startup the thermostat/schedule/automation engine stays command-passive until one full live poll of all enabled GREE units completes. Persisted device/zone/group state is still available immediately for the UI, but it is not used to emit climate commands before the physical state has been synchronized.
|
On process startup the thermostat/schedule/automation engine stays command-passive until one full live poll of all enabled GREE units completes. Persisted device/zone/group state is still available immediately for the UI, but it is not used to emit climate commands before the physical state has been synchronized.
|
||||||
|
|
||||||
`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode, uniform house preset (or `null` for mixed zone profiles), whole-house master-power state, strategy, each zone's configured `enabled` state, group-gated `effective_enabled` state, effective `mode`, `configured_mode`, `inherit_house_mode`, resolved `preset`, explicit `preset_override` (`null` = Auto schedule), current/target temperatures, physical `device_setpoint`, current schedule, manual schedule override expiry, physical/manual device override state and expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. During physical/manual takeover, `target_temperature` remains the thermostat profile/schedule target while `device_setpoint` reports the actual GREE target; automation remains paused until resumed or another explicit zone control is sent.
|
`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode, uniform house preset (or `null` for mixed zone profiles), whole-house master-power state, strategy, each zone's configured `enabled` state, group-gated `effective_enabled` state, effective `mode`, `configured_mode`, `inherit_house_mode`, resolved `preset`, explicit `preset_override` (`null` = Auto schedule), current/target temperatures, physical `device_setpoint`, current schedule, manual schedule override expiry, local quick-thermostat power ownership, 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,7 +122,7 @@ The recommended `combined` strategy keeps the GREE sensor as the primary input a
|
|||||||
|
|
||||||
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
|
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
|
||||||
|
|
||||||
From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group.
|
From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone: local ON may run the zone while its group is off, with full thermostat logic intact, and local OFF keeps that zone off until normal automation ownership is resumed. The separate zone Enabled switch remains the technical availability switch.
|
||||||
|
|
||||||
From version 0.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.
|
||||||
|
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
|||||||
@property
|
@property
|
||||||
def hvac_mode(self) -> HVACMode:
|
def hvac_mode(self) -> HVACMode:
|
||||||
zone = self._zone
|
zone = self._zone
|
||||||
if not zone.get("enabled", False):
|
if not zone.get("enabled", False) or zone.get("local_thermostat_power") is False:
|
||||||
return HVACMode.OFF
|
return HVACMode.OFF
|
||||||
if zone.get("inherit_house_mode", True):
|
if zone.get("inherit_house_mode", True):
|
||||||
return HVACMode.AUTO
|
return HVACMode.AUTO
|
||||||
@@ -329,6 +329,7 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
|||||||
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_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
|
||||||
|
"local_thermostat_power": zone.get("local_thermostat_power"),
|
||||||
"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",
|
||||||
@@ -342,13 +343,13 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
|||||||
|
|
||||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
if hvac_mode == HVACMode.OFF:
|
if hvac_mode == HVACMode.OFF:
|
||||||
payload = {"enabled": False}
|
payload = {"power": False}
|
||||||
elif hvac_mode == HVACMode.AUTO:
|
elif hvac_mode == HVACMode.AUTO:
|
||||||
payload = {"enabled": True, "mode": "house"}
|
payload = {"power": True, "mode": "house"}
|
||||||
elif hvac_mode == HVACMode.COOL:
|
elif hvac_mode == HVACMode.COOL:
|
||||||
payload = {"enabled": True, "mode": "cool"}
|
payload = {"power": True, "mode": "cool"}
|
||||||
elif hvac_mode == HVACMode.HEAT:
|
elif hvac_mode == HVACMode.HEAT:
|
||||||
payload = {"enabled": True, "mode": "heat"}
|
payload = {"power": True, "mode": "heat"}
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
await self.coordinator.async_zone_control(self._zone_id, payload)
|
await self.coordinator.async_zone_control(self._zone_id, payload)
|
||||||
@@ -365,8 +366,8 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
|||||||
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
|
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.async_zone_control(self._zone_id, {"enabled": True})
|
await self.coordinator.async_zone_control(self._zone_id, {"power": True})
|
||||||
|
|
||||||
async def async_turn_off(self) -> None:
|
async def async_turn_off(self) -> None:
|
||||||
await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
|
await self.coordinator.async_zone_control(self._zone_id, {"power": False})
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
|||||||
expected: dict[str, Any] = {}
|
expected: dict[str, Any] = {}
|
||||||
if "enabled" in payload:
|
if "enabled" in payload:
|
||||||
expected["enabled"] = bool(payload["enabled"])
|
expected["enabled"] = bool(payload["enabled"])
|
||||||
|
if "power" in payload:
|
||||||
|
expected["local_thermostat_power"] = bool(payload["power"])
|
||||||
if "mode" in payload:
|
if "mode" in payload:
|
||||||
mode = str(payload["mode"])
|
mode = str(payload["mode"])
|
||||||
if mode in {"house", "auto"}:
|
if mode in {"house", "auto"}:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.7.9",
|
"version": "0.7.10",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+9
-4
@@ -593,10 +593,15 @@
|
|||||||
"zones.manualDeviceUntil": "Manual control / remote until {time}",
|
"zones.manualDeviceUntil": "Manual control / remote until {time}",
|
||||||
"zones.manualDeviceNoBoundary": "Manual control / remote · until resumed",
|
"zones.manualDeviceNoBoundary": "Manual control / remote · until resumed",
|
||||||
"zones.resumeAutomation": "Resume automation",
|
"zones.resumeAutomation": "Resume automation",
|
||||||
"zones.unitPowerManualHint": "Controls only this unit directly like a remote; it does not enable the whole group.",
|
"zones.localThermostatControl": "Local thermostat",
|
||||||
"zones.unitPowerUpdated": "Single-unit power updated",
|
"zones.localThermostatOn": "Local control · on",
|
||||||
"zones.turnUnitOn": "Turn unit on",
|
"zones.localThermostatOff": "Local control · off",
|
||||||
"zones.turnUnitOff": "Turn unit off",
|
"zones.localThermostatOnDescription": "This zone runs locally regardless of the group power gate. Sensor, hysteresis, profile and GREE regulation remain active.",
|
||||||
|
"zones.localThermostatOffDescription": "This zone is locally off and its group cannot start it until automation is resumed.",
|
||||||
|
"zones.unitPowerLocalHint": "Turns this zone's local thermostat on or off; this is not remote-style or direct device control.",
|
||||||
|
"zones.localPowerUpdated": "Local thermostat state updated",
|
||||||
|
"zones.turnThermostatOn": "Turn thermostat on",
|
||||||
|
"zones.turnThermostatOff": "Turn thermostat off",
|
||||||
"zones.waiting": "Waiting",
|
"zones.waiting": "Waiting",
|
||||||
"zones.noMeasurement": "No measurement"
|
"zones.noMeasurement": "No measurement"
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -593,10 +593,15 @@
|
|||||||
"zones.manualDeviceUntil": "Sterowanie ręczne / pilot do {time}",
|
"zones.manualDeviceUntil": "Sterowanie ręczne / pilot do {time}",
|
||||||
"zones.manualDeviceNoBoundary": "Sterowanie ręczne / pilot · do wznowienia",
|
"zones.manualDeviceNoBoundary": "Sterowanie ręczne / pilot · do wznowienia",
|
||||||
"zones.resumeAutomation": "Wznów automatykę",
|
"zones.resumeAutomation": "Wznów automatykę",
|
||||||
"zones.unitPowerManualHint": "Steruje bezpośrednio tylko tą jednostką jak pilot; nie włącza całej grupy.",
|
"zones.localThermostatControl": "Termostat lokalny",
|
||||||
"zones.unitPowerUpdated": "Zmieniono zasilanie pojedynczej jednostki",
|
"zones.localThermostatOn": "Sterowanie lokalne · włączone",
|
||||||
"zones.turnUnitOn": "Włącz jednostkę",
|
"zones.localThermostatOff": "Sterowanie lokalne · wyłączone",
|
||||||
"zones.turnUnitOff": "Wyłącz jednostkę",
|
"zones.localThermostatOnDescription": "Strefa działa lokalnie niezależnie od bramki grupy. Czujnik, histereza, profil i regulacja GREE pozostają aktywne.",
|
||||||
|
"zones.localThermostatOffDescription": "Strefa jest lokalnie wyłączona i grupa jej nie uruchomi do czasu wznowienia automatyki.",
|
||||||
|
"zones.unitPowerLocalHint": "Włącza lub wyłącza lokalny termostat tej strefy; nie jest to sterowanie pilotem ani bezpośrednia komenda urządzenia.",
|
||||||
|
"zones.localPowerUpdated": "Zmieniono stan lokalnego termostatu",
|
||||||
|
"zones.turnThermostatOn": "Włącz termostat",
|
||||||
|
"zones.turnThermostatOff": "Wyłącz termostat",
|
||||||
"zones.waiting": "Oczekiwanie",
|
"zones.waiting": "Oczekiwanie",
|
||||||
"zones.noMeasurement": "Brak pomiaru"
|
"zones.noMeasurement": "Brak pomiaru"
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-22
@@ -530,7 +530,7 @@ impl ZoneInput {
|
|||||||
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
||||||
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
|
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
|
||||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
|
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None,
|
||||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
|
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
|
||||||
@@ -577,6 +577,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
|||||||
zone.manual_preset = existing.manual_preset;
|
zone.manual_preset = existing.manual_preset;
|
||||||
zone.manual_setpoint = existing.manual_setpoint;
|
zone.manual_setpoint = existing.manual_setpoint;
|
||||||
zone.manual_override_until = existing.manual_override_until;
|
zone.manual_override_until = existing.manual_override_until;
|
||||||
|
zone.local_thermostat_power = existing.local_thermostat_power;
|
||||||
zone.device_manual_override = existing.device_manual_override;
|
zone.device_manual_override = existing.device_manual_override;
|
||||||
zone.device_manual_override_since = existing.device_manual_override_since;
|
zone.device_manual_override_since = existing.device_manual_override_since;
|
||||||
zone.device_manual_override_until = existing.device_manual_override_until;
|
zone.device_manual_override_until = existing.device_manual_override_until;
|
||||||
@@ -605,22 +606,44 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
|||||||
Ok(Json(zone))
|
Ok(Json(zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch) -> Result<Zone, AppError> {
|
||||||
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||||
let was_enabled = zone.enabled;
|
let was_enabled = zone.enabled;
|
||||||
let schedules = state.db.list_schedules()?;
|
let schedules = state.db.list_schedules()?;
|
||||||
// Any explicit thermostat action means the user is handing ownership back to the zone
|
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
|
||||||
// controller. This includes the dedicated Resume automation button.
|
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
|
||||||
let resume_device_automation = patch.clear_device_manual_override.unwrap_or(false)
|
// Any thermostat action takes ownership back from a physical/pilot takeover. Local power
|
||||||
|| patch.setpoint.is_some() || patch.mode.is_some() || patch.preset.is_some() || patch.enabled.is_some();
|
// is a thermostat state of its own and must never be recorded as device manual control.
|
||||||
|
let resume_device_automation = resume_device_takeover
|
||||||
|
|| patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|
||||||
|
|| patch.preset.is_some() || patch.enabled.is_some();
|
||||||
|
|
||||||
|
if resume_local_thermostat {
|
||||||
|
zone.local_thermostat_power = None;
|
||||||
|
zone.manual_preset = None;
|
||||||
|
zone.manual_setpoint = None;
|
||||||
|
zone.manual_override_until = None;
|
||||||
|
}
|
||||||
|
if let Some(power) = patch.power {
|
||||||
|
zone.local_thermostat_power = Some(power);
|
||||||
|
if power { zone.enabled = true; }
|
||||||
|
// A manually started local thermostat keeps an already selected target/profile until
|
||||||
|
// the user chooses Auto or resumes global automation.
|
||||||
|
if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) {
|
||||||
|
zone.manual_override_until = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(value) = patch.setpoint {
|
if let Some(value) = patch.setpoint {
|
||||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
||||||
let value = (value * 2.0).round() / 2.0;
|
let value = (value * 2.0).round() / 2.0;
|
||||||
zone.setpoint = value;
|
zone.setpoint = value;
|
||||||
zone.manual_setpoint = Some(value);
|
zone.manual_setpoint = Some(value);
|
||||||
zone.effective_setpoint = Some(value);
|
zone.effective_setpoint = Some(value);
|
||||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(value) = patch.mode.as_deref() {
|
if let Some(value) = patch.mode.as_deref() {
|
||||||
match value {
|
match value {
|
||||||
@@ -642,7 +665,11 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
|||||||
"comfort" | "sleep" | "away" | "custom" => {
|
"comfort" | "sleep" | "away" | "custom" => {
|
||||||
zone.manual_preset = Some(value.to_string());
|
zone.manual_preset = Some(value.to_string());
|
||||||
zone.manual_setpoint = None;
|
zone.manual_setpoint = None;
|
||||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
||||||
}
|
}
|
||||||
@@ -652,7 +679,10 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
|||||||
zone.manual_setpoint = None;
|
zone.manual_setpoint = None;
|
||||||
zone.manual_override_until = None;
|
zone.manual_override_until = None;
|
||||||
}
|
}
|
||||||
if let Some(value) = patch.enabled { zone.enabled = value; }
|
if let Some(value) = patch.enabled {
|
||||||
|
zone.enabled = value;
|
||||||
|
if !value { zone.local_thermostat_power = None; }
|
||||||
|
}
|
||||||
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();
|
let house_mode = state.settings.read().await.house_mode.clone();
|
||||||
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||||
@@ -660,20 +690,33 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
|||||||
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 if patch.power == Some(false) {
|
||||||
|
if let Err(err) = engine::send_command(
|
||||||
|
state,
|
||||||
|
&zone.device_id,
|
||||||
|
DeviceCommand { power: Some(false), ..Default::default() },
|
||||||
|
).await {
|
||||||
|
state.log("error", "zone.local_power_error", &err.to_string(), json!({
|
||||||
|
"zone_id": zone.id, "device_id": zone.device_id, "power": false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
state.wake_zone_control();
|
||||||
} else {
|
} 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.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,
|
||||||
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
||||||
"override_until": zone.manual_override_until, "enabled": zone.enabled,
|
"override_until": zone.manual_override_until, "enabled": zone.enabled,
|
||||||
|
"local_thermostat_power": zone.local_thermostat_power,
|
||||||
"device_manual_override_cleared": device_override_cleared
|
"device_manual_override_cleared": device_override_cleared
|
||||||
}));
|
}));
|
||||||
Ok(Json(zone))
|
Ok(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
||||||
|
Ok(Json(apply_zone_control_patch(&state, &id, patch).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -685,17 +728,18 @@ async fn update_zone_manual_power(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<ZoneManualPowerPatch>,
|
Json(input): Json<ZoneManualPowerPatch>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
// Backward-compatible route: despite the historical name this now controls the local
|
||||||
let device = engine::send_manual_command(
|
// thermostat, not the physical unit like a pilot. Direct/pilot semantics stay on /devices.
|
||||||
|
let zone = apply_zone_control_patch(
|
||||||
&state,
|
&state,
|
||||||
&zone.device_id,
|
&id,
|
||||||
DeviceCommand { power: Some(input.power), ..Default::default() },
|
ZoneControlPatch { power: Some(input.power), ..Default::default() },
|
||||||
"zone.quick_manual_power",
|
|
||||||
).await?;
|
).await?;
|
||||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
let device = state.db.get_device(&zone.device_id)?;
|
||||||
Ok(Json(json!({"zone": zone, "device": device})))
|
Ok(Json(json!({"zone": zone, "device": device})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
|
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
|
||||||
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
|
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
|
||||||
if !device.enabled {
|
if !device.enabled {
|
||||||
@@ -930,6 +974,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
|||||||
"demand": zone.demand,
|
"demand": zone.demand,
|
||||||
"control_source": zone.control_source,
|
"control_source": zone.control_source,
|
||||||
"current_schedule": zone.current_schedule_name,
|
"current_schedule": zone.current_schedule_name,
|
||||||
|
"local_thermostat_power": zone.local_thermostat_power,
|
||||||
"device_manual_override": zone.device_manual_override,
|
"device_manual_override": zone.device_manual_override,
|
||||||
"device_manual_override_until": zone.device_manual_override_until,
|
"device_manual_override_until": zone.device_manual_override_until,
|
||||||
})).collect::<Vec<_>>();
|
})).collect::<Vec<_>>();
|
||||||
@@ -981,11 +1026,24 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
let mut cleared = 0;
|
||||||
|
for mut zone in state.db.list_zones()? {
|
||||||
|
if zone.local_thermostat_power.is_none() { continue; }
|
||||||
|
zone.local_thermostat_power = None;
|
||||||
|
zone.updated_at = Utc::now();
|
||||||
|
state.db.save_zone(&zone)?;
|
||||||
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||||
|
cleared += 1;
|
||||||
|
}
|
||||||
|
Ok(cleared)
|
||||||
|
}
|
||||||
|
|
||||||
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
||||||
let mut failed = Vec::new();
|
let mut failed = Vec::new();
|
||||||
let enabled_zone_devices: std::collections::HashSet<String> = if power {
|
let enabled_zone_devices: std::collections::HashSet<String> = if power {
|
||||||
state.db.list_zones()?.into_iter()
|
state.db.list_zones()?.into_iter()
|
||||||
.filter(|zone| zone.enabled && !zone.device_manual_override)
|
.filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false))
|
||||||
.map(|zone| zone.device_id)
|
.map(|zone| zone.device_id)
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
@@ -1074,6 +1132,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
|||||||
set_all_groups_power(&state, input.power)?;
|
set_all_groups_power(&state, input.power)?;
|
||||||
if !input.power {
|
if !input.power {
|
||||||
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
|
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
|
||||||
|
clear_all_local_thermostat_overrides(&state)?;
|
||||||
}
|
}
|
||||||
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
|
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
|
||||||
|
|
||||||
|
|||||||
+182
-15
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
home_assistant,
|
home_assistant,
|
||||||
influxdb,
|
influxdb,
|
||||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||||
state::AppState,
|
state::{AppState, PendingControllerCommand},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn start(state: AppState) {
|
pub fn start(state: AppState) {
|
||||||
@@ -117,11 +117,11 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||||
send_command_locked_inner(state, device_id, command, true).await
|
send_command_locked_inner(state, device_id, command, true, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||||
send_command_locked_inner(state, device_id, command, false).await
|
send_command_locked_inner(state, device_id, command, false, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_command_locked_inner(
|
async fn send_command_locked_inner(
|
||||||
@@ -129,6 +129,7 @@ async fn send_command_locked_inner(
|
|||||||
device_id: &str,
|
device_id: &str,
|
||||||
command: DeviceCommand,
|
command: DeviceCommand,
|
||||||
dedupe_against_cache: bool,
|
dedupe_against_cache: bool,
|
||||||
|
track_controller_command: bool,
|
||||||
) -> Result<Device, AppError> {
|
) -> Result<Device, AppError> {
|
||||||
validate_command(&command)?;
|
validate_command(&command)?;
|
||||||
let mut device = state.db.get_device(device_id)?
|
let mut device = state.db.get_device(device_id)?
|
||||||
@@ -253,11 +254,24 @@ async fn send_command_locked_inner(
|
|||||||
}
|
}
|
||||||
state.db.save_device(&device)?;
|
state.db.save_device(&device)?;
|
||||||
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
|
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
|
||||||
|
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
||||||
|
remember_controller_command(state, device_id, &applied_command).await;
|
||||||
|
}
|
||||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||||
return Err(AppError::Device("device did not confirm the requested forced state change".into()));
|
return Err(AppError::Device("device did not confirm the requested forced state change".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if track_controller_command {
|
||||||
|
if confirmed_state && confirmed_requested_state {
|
||||||
|
// A verified full status snapshot supersedes any older unsettled expectation for
|
||||||
|
// this device, so it must not mask a later real remote change.
|
||||||
|
clear_pending_controller_command(state, device_id).await;
|
||||||
|
} else if !command_manual_control_fields(&applied_command).is_empty() {
|
||||||
|
remember_controller_command(state, device_id, &applied_command).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||||
"device_id": device.id,
|
"device_id": device.id,
|
||||||
"command": applied_command,
|
"command": applied_command,
|
||||||
@@ -278,7 +292,7 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, Ap
|
|||||||
let before = device.clone();
|
let before = device.clone();
|
||||||
poll_device(state, &mut device).await;
|
poll_device(state, &mut device).await;
|
||||||
if poll_completed_successfully(&device) {
|
if poll_completed_successfully(&device) {
|
||||||
detect_external_device_control(state, &before, &device)?;
|
detect_external_device_control(state, &before, &device).await?;
|
||||||
}
|
}
|
||||||
state.db.save_device(&device)?;
|
state.db.save_device(&device)?;
|
||||||
record_reading(state, &device)?;
|
record_reading(state, &device)?;
|
||||||
@@ -453,6 +467,83 @@ fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
|
|||||||
fields
|
fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_device_command(base: &mut DeviceCommand, update: &DeviceCommand) {
|
||||||
|
if update.power.is_some() { base.power = update.power; }
|
||||||
|
if update.mode.is_some() { base.mode = update.mode.clone(); }
|
||||||
|
if update.target_temperature.is_some() { base.target_temperature = update.target_temperature; }
|
||||||
|
if update.fan_speed.is_some() { base.fan_speed = update.fan_speed; }
|
||||||
|
if update.swing_vertical.is_some() { base.swing_vertical = update.swing_vertical; }
|
||||||
|
if update.swing_horizontal.is_some() { base.swing_horizontal = update.swing_horizontal; }
|
||||||
|
if update.quiet.is_some() { base.quiet = update.quiet; }
|
||||||
|
if update.turbo.is_some() { base.turbo = update.turbo; }
|
||||||
|
if update.light.is_some() { base.light = update.light; }
|
||||||
|
if update.air.is_some() { base.air = update.air; }
|
||||||
|
if update.xfan.is_some() { base.xfan = update.xfan; }
|
||||||
|
if update.health.is_some() { base.health = update.health; }
|
||||||
|
if update.sleep.is_some() { base.sleep = update.sleep; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand) {
|
||||||
|
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
|
||||||
|
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
|
||||||
|
let mut pending = state.pending_controller_commands.lock().await;
|
||||||
|
let expires_at = Instant::now() + ttl;
|
||||||
|
if let Some(existing) = pending.get_mut(device_id) {
|
||||||
|
merge_device_command(&mut existing.command, command);
|
||||||
|
existing.expires_at = expires_at;
|
||||||
|
} else {
|
||||||
|
pending.insert(device_id.to_string(), PendingControllerCommand {
|
||||||
|
command: command.clone(),
|
||||||
|
expires_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_pending_controller_command(state: &AppState, device_id: &str) {
|
||||||
|
state.pending_controller_commands.lock().await.remove(device_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
|
||||||
|
match field {
|
||||||
|
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
|
||||||
|
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false),
|
||||||
|
"target_temperature" => command.target_temperature
|
||||||
|
.map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).round())
|
||||||
|
.unwrap_or(false),
|
||||||
|
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false),
|
||||||
|
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false),
|
||||||
|
"sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn suppress_expected_controller_changes(
|
||||||
|
state: &AppState,
|
||||||
|
device: &Device,
|
||||||
|
fields: Vec<String>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
if fields.is_empty() { return fields; }
|
||||||
|
let mut pending = state.pending_controller_commands.lock().await;
|
||||||
|
let expired = pending.get(&device.id)
|
||||||
|
.map(|expected| Instant::now() > expected.expires_at)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if expired {
|
||||||
|
pending.remove(&device.id);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
|
||||||
|
let filtered = fields.into_iter()
|
||||||
|
.filter(|field| !command_field_matches_device(&expected.command, field, device))
|
||||||
|
.collect();
|
||||||
|
let expected_fields = command_manual_control_fields(&expected.command);
|
||||||
|
if !expected_fields.is_empty()
|
||||||
|
&& expected_fields.iter().all(|field| command_field_matches_device(&expected.command, field, device))
|
||||||
|
{
|
||||||
|
pending.remove(&device.id);
|
||||||
|
}
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
|
||||||
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
|
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
if before.power != after.power { fields.push("power".to_string()); }
|
if before.power != after.power { fields.push("power".to_string()); }
|
||||||
@@ -551,10 +642,14 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
||||||
if before.id != after.id { return Ok(()); }
|
if before.id != after.id { return Ok(()); }
|
||||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
||||||
let fields = externally_changed_control_fields(before, after, &zone);
|
let fields = suppress_expected_controller_changes(
|
||||||
|
state,
|
||||||
|
after,
|
||||||
|
externally_changed_control_fields(before, after, &zone),
|
||||||
|
).await;
|
||||||
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
|
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
|
||||||
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
|
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
|
||||||
continue;
|
continue;
|
||||||
@@ -579,7 +674,7 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
|
|||||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||||
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
|
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
|
||||||
let fields = command_manual_control_fields(&effective_command);
|
let fields = command_manual_control_fields(&effective_command);
|
||||||
let updated = send_command_locked(state, device_id, command).await?;
|
let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
|
||||||
if !fields.is_empty() {
|
if !fields.is_empty() {
|
||||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
||||||
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
|
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
|
||||||
@@ -771,7 +866,8 @@ fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateT
|
|||||||
async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
||||||
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
||||||
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
||||||
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override { return Ok(false); }
|
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); }
|
||||||
|
if zone.local_thermostat_power == Some(true) { return Ok(true); }
|
||||||
let blocked = state.db.list_groups()?.iter().any(|group| {
|
let blocked = state.db.list_groups()?.iter().any(|group| {
|
||||||
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
||||||
});
|
});
|
||||||
@@ -781,7 +877,7 @@ async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device
|
|||||||
async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
||||||
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
||||||
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
||||||
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override { return Ok(false); }
|
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(false); }
|
||||||
Ok(state.db.list_groups()?.iter().any(|group| {
|
Ok(state.db.list_groups()?.iter().any(|group| {
|
||||||
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
||||||
}))
|
}))
|
||||||
@@ -816,7 +912,7 @@ async fn send_group_power_if_current(
|
|||||||
) -> Result<Option<Device>, AppError> {
|
) -> Result<Option<Device>, AppError> {
|
||||||
let _device_guard = state.lock_device_operation(device_id).await;
|
let _device_guard = state.lock_device_operation(device_id).await;
|
||||||
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); };
|
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); };
|
||||||
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override { return Ok(None); }
|
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); }
|
||||||
let groups = state.db.list_groups()?;
|
let groups = state.db.list_groups()?;
|
||||||
let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); };
|
let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); };
|
||||||
if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); }
|
if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); }
|
||||||
@@ -853,6 +949,7 @@ async fn send_automatic_device_command_if_owned(
|
|||||||
let zones = state.db.list_zones()?;
|
let zones = state.db.list_zones()?;
|
||||||
if device_blocked_by_disabled_zone(device_id, &zones)
|
if device_blocked_by_disabled_zone(device_id, &zones)
|
||||||
|| device_blocked_by_manual_override(device_id, &zones)
|
|| device_blocked_by_manual_override(device_id, &zones)
|
||||||
|
|| device_blocked_by_local_thermostat(device_id, &zones)
|
||||||
|| device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?)
|
|| device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?)
|
||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1052,7 +1149,32 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
if zone.local_thermostat_power == Some(false) {
|
||||||
|
zone.effective_mode = "off".into();
|
||||||
|
zone.demand = false;
|
||||||
|
zone.demand_since = None;
|
||||||
|
zone.device_setpoint = None;
|
||||||
|
if device.online && device.communication_failures == 0 && device.power {
|
||||||
|
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||||
|
let latest = state.db.get_zone(&zone.id)?;
|
||||||
|
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
|
||||||
|
if let Err(err) = send_command_locked(
|
||||||
|
state,
|
||||||
|
&zone.device_id,
|
||||||
|
DeviceCommand { power: Some(false), ..Default::default() },
|
||||||
|
).await {
|
||||||
|
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||||
|
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||||
|
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||||
|
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||||
if blocked_by_group {
|
if blocked_by_group {
|
||||||
zone.effective_mode = "off".into();
|
zone.effective_mode = "off".into();
|
||||||
zone.demand = false;
|
zone.demand = false;
|
||||||
@@ -1680,11 +1802,12 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
|||||||
} else {
|
} else {
|
||||||
zone.mode.as_str()
|
zone.mode.as_str()
|
||||||
};
|
};
|
||||||
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
let blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||||
|
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||||
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
|
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
|
||||||
let effective_mode = if zone.device_manual_override {
|
let effective_mode = if zone.device_manual_override {
|
||||||
manual_device_mode.unwrap_or(configured_effective_mode)
|
manual_device_mode.unwrap_or(configured_effective_mode)
|
||||||
} else if blocked_by_group {
|
} else if zone.local_thermostat_power == Some(false) || blocked_by_group {
|
||||||
"off"
|
"off"
|
||||||
} else {
|
} else {
|
||||||
configured_effective_mode
|
configured_effective_mode
|
||||||
@@ -1706,7 +1829,9 @@ 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);
|
let effective_enabled = zone.enabled
|
||||||
|
&& zone.local_thermostat_power != Some(false)
|
||||||
|
&& (!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(),
|
||||||
@@ -1731,6 +1856,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
|||||||
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
|
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
|
||||||
control_source: zone.control_temperature_source.clone(),
|
control_source: zone.control_temperature_source.clone(),
|
||||||
manual_override_until: zone.manual_override_until,
|
manual_override_until: zone.manual_override_until,
|
||||||
|
local_thermostat_power: zone.local_thermostat_power,
|
||||||
device_manual_override: zone.device_manual_override,
|
device_manual_override: zone.device_manual_override,
|
||||||
device_manual_override_until: zone.device_manual_override_until,
|
device_manual_override_until: zone.device_manual_override_until,
|
||||||
current_schedule_id: active.map(|item| item.id.clone()),
|
current_schedule_id: active.map(|item| item.id.clone()),
|
||||||
@@ -1919,6 +2045,15 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
|
||||||
|
item.last_fired_at = Some(Utc::now());
|
||||||
|
item.updated_at = Utc::now();
|
||||||
|
state.db.save_automation(&item)?;
|
||||||
|
state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({
|
||||||
|
"automation_id": item.id, "device_id": item.action_device_id
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
|
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
|
||||||
// Group power-off is authoritative for normal controller-owned zones. A manual
|
// Group power-off is authoritative for normal controller-owned zones. A manual
|
||||||
// takeover is filtered above and therefore remains higher priority than the group.
|
// takeover is filtered above and therefore remains higher priority than the group.
|
||||||
@@ -1979,6 +2114,9 @@ fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool {
|
|||||||
zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override)
|
zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool {
|
||||||
|
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
|
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
|
||||||
let zone_ids: std::collections::HashSet<&str> = zones.iter()
|
let zone_ids: std::collections::HashSet<&str> = zones.iter()
|
||||||
@@ -2120,7 +2258,7 @@ mod tests {
|
|||||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||||
manual_preset: None, manual_setpoint: None, manual_override_until: None,
|
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None,
|
||||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||||
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
@@ -2141,6 +2279,35 @@ mod tests {
|
|||||||
assert!(fields.iter().any(|field| field == "fan_speed"));
|
assert!(fields.iter().any(|field| field == "fan_speed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controller_expected_climate_change_is_recognized() {
|
||||||
|
let mut device = Device::simulated_default();
|
||||||
|
device.power = true;
|
||||||
|
device.mode = "cool".into();
|
||||||
|
device.target_temperature = 22.0;
|
||||||
|
let command = DeviceCommand {
|
||||||
|
power: Some(true),
|
||||||
|
mode: Some("cool".into()),
|
||||||
|
target_temperature: Some(22.0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(command_field_matches_device(&command, "power", &device));
|
||||||
|
assert!(command_field_matches_device(&command, "mode", &device));
|
||||||
|
assert!(command_field_matches_device(&command, "target_temperature", &device));
|
||||||
|
device.target_temperature = 25.0;
|
||||||
|
assert!(!command_field_matches_device(&command, "target_temperature", &device));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_thermostat_ownership_blocks_direct_automation() {
|
||||||
|
let mut zone = test_zone("device");
|
||||||
|
assert!(!device_blocked_by_local_thermostat(&zone.device_id, &[zone.clone()]));
|
||||||
|
zone.local_thermostat_power = Some(true);
|
||||||
|
assert!(device_blocked_by_local_thermostat(&zone.device_id, &[zone.clone()]));
|
||||||
|
zone.local_thermostat_power = Some(false);
|
||||||
|
assert!(device_blocked_by_local_thermostat(&zone.device_id, &[zone]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rounded_gree_setpoint_does_not_create_manual_override() {
|
fn rounded_gree_setpoint_does_not_create_manual_override() {
|
||||||
let zone = test_zone("device");
|
let zone = test_zone("device");
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async fn main() -> Result<()> {
|
|||||||
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()),
|
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())),
|
||||||
|
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,10 @@ pub struct Zone {
|
|||||||
pub manual_setpoint: Option<f64>,
|
pub manual_setpoint: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manual_override_until: Option<DateTime<Utc>>,
|
pub manual_override_until: Option<DateTime<Utc>>,
|
||||||
|
/// Local quick-thermostat power override. None follows group/global power gates;
|
||||||
|
/// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off.
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_thermostat_power: Option<bool>,
|
||||||
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
|
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
|
||||||
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
|
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -409,6 +413,9 @@ pub struct GroupControlPatch {
|
|||||||
pub struct ZoneControlPatch {
|
pub struct ZoneControlPatch {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub setpoint: Option<f64>,
|
pub setpoint: Option<f64>,
|
||||||
|
/// Local quick-thermostat power. This is thermostat ownership, not direct device/pilot control.
|
||||||
|
#[serde(default)]
|
||||||
|
pub power: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: Option<String>,
|
pub mode: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -421,6 +428,9 @@ pub struct ZoneControlPatch {
|
|||||||
/// Explicitly hand control of a manually overridden physical unit back to the thermostat engine.
|
/// Explicitly hand control of a manually overridden physical unit back to the thermostat engine.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub clear_device_manual_override: Option<bool>,
|
pub clear_device_manual_override: Option<bool>,
|
||||||
|
/// Return a locally forced quick thermostat to normal group/schedule ownership.
|
||||||
|
#[serde(default)]
|
||||||
|
pub clear_local_thermostat_override: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -727,6 +737,7 @@ pub struct ZoneControlPlan {
|
|||||||
pub demand: bool,
|
pub demand: bool,
|
||||||
pub control_source: String,
|
pub control_source: String,
|
||||||
pub manual_override_until: Option<DateTime<Utc>>,
|
pub manual_override_until: Option<DateTime<Utc>>,
|
||||||
|
pub local_thermostat_power: Option<bool>,
|
||||||
pub device_manual_override: bool,
|
pub device_manual_override: bool,
|
||||||
pub device_manual_override_until: Option<DateTime<Utc>>,
|
pub device_manual_override_until: Option<DateTime<Utc>>,
|
||||||
pub current_schedule_id: Option<String>,
|
pub current_schedule_id: Option<String>,
|
||||||
|
|||||||
+10
-1
@@ -2,7 +2,13 @@ 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, Notify, 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, DeviceCommand, RuntimeSettings}, protocol::GreeClient};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PendingControllerCommand {
|
||||||
|
pub command: DeviceCommand,
|
||||||
|
pub expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -20,6 +26,9 @@ pub struct AppState {
|
|||||||
/// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval.
|
/// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval.
|
||||||
pub zone_control_wakeup: Arc<Notify>,
|
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<()>>>>>,
|
||||||
|
/// Short-lived expected climate state from controller-originated commands. It prevents
|
||||||
|
/// a delayed GREE status update from being mistaken for remote/manual takeover.
|
||||||
|
pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>,
|
||||||
pub started: Instant,
|
pub started: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-17
@@ -692,6 +692,7 @@ function zonePresetLabel(preset) {
|
|||||||
function zoneRuntimeStatusLabel(zone, effectiveMode) {
|
function zoneRuntimeStatusLabel(zone, effectiveMode) {
|
||||||
if (zone.device_manual_override === true) return tr('zones.manualDeviceControl');
|
if (zone.device_manual_override === true) return tr('zones.manualDeviceControl');
|
||||||
if (!zone.enabled) return tr('common.disabled');
|
if (!zone.enabled) return tr('common.disabled');
|
||||||
|
if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff');
|
||||||
if ((effectiveMode || 'off') === 'off') return tr('zones.waiting');
|
if ((effectiveMode || 'off') === 'off') return tr('zones.waiting');
|
||||||
if (zone.current_temperature == null) return tr('zones.noMeasurement');
|
if (zone.current_temperature == null) return tr('zones.noMeasurement');
|
||||||
return zone.demand ? tr('zones.requesting') : tr('zones.satisfied');
|
return zone.demand ? tr('zones.requesting') : tr('zones.satisfied');
|
||||||
@@ -711,18 +712,25 @@ function zoneCard(zone, detailed = true) {
|
|||||||
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;
|
||||||
const deviceManualOverride = zone.device_manual_override === true;
|
const deviceManualOverride = zone.device_manual_override === true;
|
||||||
|
const localThermostatPower = zone.local_thermostat_power;
|
||||||
|
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
|
||||||
const override = deviceManualOverride
|
const override = deviceManualOverride
|
||||||
? (zone.device_manual_override_until
|
? (zone.device_manual_override_until
|
||||||
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
|
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
|
||||||
: tr('zones.manualDeviceNoBoundary'))
|
: tr('zones.manualDeviceNoBoundary'))
|
||||||
: (zone.manual_override_until
|
: localThermostatOverride
|
||||||
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
|
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
|
||||||
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
|
: (zone.manual_override_until
|
||||||
|
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
|
||||||
|
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
|
||||||
const manualTakeover = deviceManualOverride
|
const manualTakeover = deviceManualOverride
|
||||||
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
|
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="device" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
|
||||||
: '';
|
: localThermostatOverride
|
||||||
const manualPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
|
? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong><p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
|
||||||
const manualUnitPower = `<div class="zone-manual-power"><button type="button" class="zone-unit-power-toggle ${device?.power ? 'active' : ''}" title="${esc(tr('zones.unitPowerManualHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${device?.power ? 'false' : 'true'}" aria-pressed="${device?.power ? 'true' : 'false'}"${manualPowerDisabled}>${esc(tr(device?.power ? 'zones.turnUnitOff' : 'zones.turnUnitOn'))}</button></div>`;
|
: '';
|
||||||
|
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
|
||||||
|
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
|
||||||
|
const manualUnitPower = `<div class="zone-manual-power"><button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}>${esc(tr(localRequestedOn ? 'zones.turnThermostatOff' : 'zones.turnThermostatOn'))}</button></div>`;
|
||||||
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
|
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
|
||||||
@@ -1126,15 +1134,12 @@ function updateDevice(device) {
|
|||||||
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
|
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendZoneManualPower(id, power) {
|
async function sendZoneLocalPower(id, power) {
|
||||||
try {
|
try {
|
||||||
const result = await api(`/api/zones/${encodeURIComponent(id)}/manual-power`, {method:'POST', body:{power}});
|
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{power}});
|
||||||
if (result.device) updateDevice(result.device);
|
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||||
if (result.zone) {
|
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||||
const index = app.zones.findIndex(item => item.id === result.zone.id);
|
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated'));
|
||||||
if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone);
|
|
||||||
}
|
|
||||||
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.unitPowerUpdated'));
|
|
||||||
} catch (error) { toast(error.message, true); }
|
} catch (error) { toast(error.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1751,12 +1756,17 @@ document.addEventListener('click', async event => {
|
|||||||
if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'});
|
if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'});
|
||||||
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
|
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
|
||||||
if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value});
|
if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value});
|
||||||
if (action === 'zone-device-power') return sendZoneManualPower(button.dataset.id, button.dataset.value === 'true');
|
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
|
||||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
|
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
|
||||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
||||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
||||||
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'});
|
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'});
|
||||||
if (action === 'zone-resume-automation') return sendZoneControl(button.dataset.id,{clear_device_manual_override:true});
|
if (action === 'zone-resume-automation') {
|
||||||
|
const patch = button.dataset.resume === 'local'
|
||||||
|
? {clear_local_thermostat_override:true}
|
||||||
|
: {clear_device_manual_override:true};
|
||||||
|
return sendZoneControl(button.dataset.id, patch);
|
||||||
|
}
|
||||||
if (action === 'zone-go-control') {
|
if (action === 'zone-go-control') {
|
||||||
showView('dashboard', {scroll:false});
|
showView('dashboard', {scroll:false});
|
||||||
const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id);
|
const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'gree-controller-v079-zone-profile-control';
|
const CACHE = 'gree-controller-v0710-local-thermostat-ownership';
|
||||||
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