This commit is contained in:
Mateusz Gruszczyński
2026-08-27 17:17:05 +02:00
parent 65eaf50fb6
commit 62514258d3
16 changed files with 205 additions and 43 deletions
+24 -2
View File
@@ -1,8 +1,8 @@
# GREE Controller v0.7.11 - build and validation report # GREE Controller v0.7.12 - build and validation report
## Scope ## Scope
Version 0.7.11 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. Version 0.7.12 adds a timed hand-back for per-zone Quick Thermostat OFF while preserving the strict separation between local thermostat ownership and true physical/manual-device takeover. After 15 minutes the zone returns to the current group/schedule logic instead of restoring stale physical state.
## Implemented changes ## Implemented changes
@@ -436,3 +436,25 @@ Validation for this package is recorded after the final archive/manifest checks.
- Cargo package and Home Assistant manifest report version `0.7.11`; PWA cache metadata was rotated for this release. - Cargo package and Home Assistant manifest report version `0.7.11`; PWA cache metadata was rotated for this release.
- The reported Rust E0505 test case was corrected with borrow-only `std::slice::from_ref(&zone)` calls. - The reported Rust E0505 test case was corrected with borrow-only `std::slice::from_ref(&zone)` calls.
- This packaging environment does not provide `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment. - This packaging environment does not provide `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment.
## 2026-08-27 v0.7.12 timed local-thermostat resume
- Added persisted `local_thermostat_resume_at` to each zone.
- Local Quick Thermostat ON has no timeout; local OFF powers the unit down immediately and schedules automatic automation hand-back after exactly 15 minutes.
- The control loop now sleeps only until the earlier of its normal interval or the nearest local-resume deadline, so the 15-minute hand-back is not delayed by a long zone interval and remains restart-safe.
- Expiry clears local power ownership and quick preset/setpoint overrides, then re-evaluates the current group, house mode, schedule, temperature and hysteresis. It never restores a stale physical ON/OFF snapshot.
- Physical/pilot `device_manual_override` is intentionally untouched by the local timer.
- Web UI displays a live `MM:SS` countdown and **Resume now** during the local-OFF window.
- Persisted 0.7.10/0.7.11 local-OFF records without a deadline receive a fresh 15-minute deadline on first control cycle after upgrade.
- Home Assistant zone attributes expose `local_thermostat_resume_at`.
- Added a unit test that verifies local hand-back clears local quick state without clearing a real physical-device takeover.
## Final validation for v0.7.12
- Web JavaScript passes `node --check`.
- Home Assistant modules and helper scripts pass `python3 -m py_compile`.
- JSON and TOML metadata parse successfully; Polish and English translation packs have identical key sets.
- Shell scripts pass `bash -n`; HTML parses and CSS braces are balanced.
- Source references for `local_thermostat_resume_at` are consistent across model, API, engine, Web UI, control-plan output and Home Assistant attributes.
- Version metadata is aligned at `0.7.12` and the PWA cache key is rotated.
- This packaging environment still has no `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment.
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.7.11" version = "0.7.12"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.7.11" version = "0.7.12"
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"
+15 -15
View File
@@ -1,29 +1,29 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
d660312751ad5ddbc730d25ddd2181b1c4c8be4a8da18e2b23eaa39ca00c1d63 ./BUILD_REPORT.md b2539a47c2594c4abfb54965c01a0bac18f6bcbd70ed3f546201819ee793e5e1 ./BUILD_REPORT.md
83f9d4ee92b722c3f81bb739a55d4b2d66bbd09276c31876cbf4f610a812c280 ./Cargo.lock 7e3367a68ba90e5e5139fde8842cf5d81ac87a154aeda44a8331be946207da7d ./Cargo.lock
d42b2cdbf06555dd327405c9f84c14b7f222faa545c4e6f5d9fde8b6e9ad55ac ./Cargo.toml e97cb5f6ccf91a32b2c97560140f1ea0314b27d61039fbd03564ceacfab7fe31 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
f846576df3ace004a7c4f3cd104242cbc38814b383938d6d914ad3ebc085c71e ./README.md 8a7844b5774ded9e19917d27c509c891062b4e06eb79e811f1c382fd31121d37 ./README.md
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
38bbc16b8350ca5f70646b40d1b7d0a9231425208c99f4f2d17979431c0c289a ./docs/API.md 61977236e655ede9be9fb3ac1342fca3ca5f466f41ded5385515bc5c86a974b9 ./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
8cd55020fd740bc4b26ee6215218ea9c49ba37e1566e2128831c004bd35c52a2 ./home-assistant/README.md f617d0b003a6786e4da144b12134451aba6641d91d4e9aa690d5c0009de3a83c ./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
15b7430190bec9b82690d4073b1371d78746d01931874d45a05858ea4334cc37 ./home-assistant/custom_components/gree_controller/climate.py 3d0169ddabe6c664f71e76582995ac749c35214ca717654d296f4da8b909f2bf ./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
b065b61ef1adafe96aae3e47bff3e8b0bba57796fdfac1503669046beb1c0157 ./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
7a0171c08b8edd44369416b067c0a267b88c8fb28fbf34125385d0d6c9e308cb ./home-assistant/custom_components/gree_controller/manifest.json ef77626f30454babda4c8fc3c80196a40d500b07db99d3f0640c3460fbe7b8e9 ./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
cc404db7cac21c39c0c5d61ead78a36835beb0fa599db1da02df1b69044dbc3b ./lang/en.json f8401fc91cce6b11a62c1624c3c67f075f145870c4caa13a295701cb2253fa2a ./lang/en.json
ae856c40dc495337f649f347cfbf1842f8a25ca803c3413d9ab2b6c181427a2a ./lang/pl.json cedd112847c3053dd3e130f77d1ed033d7bce70dff159a37d0ec9b5165e9dd20 ./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,15 +46,15 @@ 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
9a9c8267a8b4dab192b1d97d2ef3a255338e2a3831f7bf33ce4b18fb85f74f19 ./src/api.rs c5d7839844b823966f2dac3f9a89e2b323a9c349840cb8a0edc571f1b055ae31 ./src/api.rs
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
2c7f21d18d04d63691f0856b895fd29cd625861220773dce9e74a52d5d241619 ./src/engine.rs b2ca8bc21f80734873a56792bdced9c85f7cdb6cd1c00d03ab161a8dd042d288 ./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
4ae0cc270938a9626148490c04c69b01cda75c30547a63a6a1b377b2109a0f9c ./src/main.rs 4ae0cc270938a9626148490c04c69b01cda75c30547a63a6a1b377b2109a0f9c ./src/main.rs
fccf60732e66ff7d185ee8324196e9cea29a220a610a551b3b830799ef4cdbc4 ./src/models.rs 7de365e80b73a16b9429911fa683e0425830be7041fc889b594332de81d72cd5 ./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
@@ -62,10 +62,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
6081635986e1ce39a0e195c3976ae30a033454fe90ec80bcbdf05fc26bf7087f ./src/state.rs 6081635986e1ce39a0e195c3976ae30a033454fe90ec80bcbdf05fc26bf7087f ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
23dcfdfc9eb2093c7289dad522dcaec76c802c7b45dbd58e074b3bdf8be2f323 ./web/app.js 0ffb96f6d494b24632c0825b76c21a88d2d9444e35208c854a01495c36369ec6 ./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
59df58dc528fbbbb5b6097df7110e78d35951506cc056816c7b2be51f4d51965 ./web/styles.css 59df58dc528fbbbb5b6097df7110e78d35951506cc056816c7b2be51f4d51965 ./web/styles.css
f7d2bae7f7abe896ad56996b5423547e4edd056c9bcd7fedbbf89ea91f5bacee ./web/sw.js 6206538cd2f546463387af542515137c3ae3e100ff0d4843a6af73a05705283b ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+10 -2
View File
@@ -4,7 +4,7 @@
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
Current version: **0.7.11**. Current version: **0.7.12**.
## 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.11.zip unzip gree-controller-v0.7.12.zip
cd gree-controller cd gree-controller
chmod +x scripts/*.sh chmod +x scripts/*.sh
./scripts/dev.sh ./scripts/dev.sh
@@ -478,6 +478,14 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C
## 0.7.12 Timed quick-thermostat hand-back
- Switching a single Quick Thermostat **OFF** now starts a 15-minute local-OFF window instead of leaving the zone outside automation indefinitely.
- The Web UI shows a live `MM:SS` countdown and a **Resume now** action during that window.
- At expiry, only local quick-thermostat ownership and its temporary quick profile/setpoint are cleared. The controller does not restore a stale physical ON/OFF state; it recalculates the current group, house mode, schedule, profile, room temperature and hysteresis.
- Switching the local thermostat back **ON** cancels the pending hand-back. A physical/remote `device_manual_override` remains separate and is not cleared by the local timer.
- Persisted local-OFF state from 0.7.10/0.7.11 receives a fresh 15-minute hand-back deadline on the first controller cycle after upgrade.
## 0.7.11 Cleanup and Rust test fix ## 0.7.11 Cleanup and Rust test fix
- Removed the obsolete `/api/zones/{id}/manual-power` compatibility endpoint and its handler; local thermostat power has one canonical API path: `/api/zones/{id}/control`. - Removed the obsolete `/api/zones/{id}/manual-power` compatibility endpoint and its handler; local thermostat power has one canonical API path: `/api/zones/{id}/control`.
+2 -2
View File
@@ -137,7 +137,7 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow global mode** in the UI and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones. `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. Direct/pilot-style control remains `/api/devices/{id}/command`. `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 and cancels any pending local hand-back timer. `false` turns only that zone off immediately and sets `local_thermostat_resume_at` to 15 minutes in the future. When that deadline is reached, the controller clears local quick ownership plus its temporary quick preset/setpoint and recalculates the current group/schedule/house-mode state instead of restoring an old physical ON/OFF snapshot. `clear_local_thermostat_override:true` performs that hand-back immediately. Direct/pilot-style control remains `/api/devices/{id}/command`.
### Physical/manual device takeover ### Physical/manual device takeover
@@ -217,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, 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. `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 and automatic resume deadline, 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"
+1 -1
View File
@@ -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.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.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. From 0.7.12, local OFF keeps that zone off for 15 minutes and then automatically returns ownership to the current group/schedule logic; the deadline is exposed as `local_thermostat_resume_at`. 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.
@@ -330,6 +330,7 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
"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"), "local_thermostat_power": zone.get("local_thermostat_power"),
"local_thermostat_resume_at": zone.get("local_thermostat_resume_at"),
"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",
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.7.11", "version": "0.7.12",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
+5 -2
View File
@@ -597,12 +597,15 @@
"zones.localThermostatOn": "Local control · on", "zones.localThermostatOn": "Local control · on",
"zones.localThermostatOff": "Local control · off", "zones.localThermostatOff": "Local control · off",
"zones.localThermostatOnDescription": "This zone runs locally regardless of the group power gate. Sensor, hysteresis, profile and GREE regulation remain active.", "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.localThermostatOffDescription": "This zone is locally off. Without an active countdown it remains outside group control until automation is resumed manually.",
"zones.unitPowerLocalHint": "Turns this zone's local thermostat on or off; this is not remote-style or direct device control.", "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.localPowerUpdated": "Local thermostat state updated",
"zones.turnThermostatOn": "Turn thermostat on", "zones.turnThermostatOn": "Turn thermostat on",
"zones.turnThermostatOff": "Turn thermostat off", "zones.turnThermostatOff": "Turn thermostat off",
"zones.waiting": "Waiting", "zones.waiting": "Waiting",
"zones.noMeasurement": "No measurement" "zones.noMeasurement": "No measurement",
"zones.localThermostatOffDescriptionTimed": "This zone is locally off. Automation takes control again in {time}.",
"zones.localThermostatResuming": "Timer elapsed — automation is taking control…",
"zones.resumeNow": "Resume now"
} }
} }
+5 -2
View File
@@ -597,12 +597,15 @@
"zones.localThermostatOn": "Sterowanie lokalne · włączone", "zones.localThermostatOn": "Sterowanie lokalne · włączone",
"zones.localThermostatOff": "Sterowanie lokalne · wyłączone", "zones.localThermostatOff": "Sterowanie lokalne · wyłączone",
"zones.localThermostatOnDescription": "Strefa działa lokalnie niezależnie od bramki grupy. Czujnik, histereza, profil i regulacja GREE pozostają aktywne.", "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.localThermostatOffDescription": "Strefa jest lokalnie wyłączona. Bez aktywnego licznika pozostaje poza sterowaniem grupy do ręcznego 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.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.localPowerUpdated": "Zmieniono stan lokalnego termostatu",
"zones.turnThermostatOn": "Włącz termostat", "zones.turnThermostatOn": "Włącz termostat",
"zones.turnThermostatOff": "Wyłącz termostat", "zones.turnThermostatOff": "Wyłącz termostat",
"zones.waiting": "Oczekiwanie", "zones.waiting": "Oczekiwanie",
"zones.noMeasurement": "Brak pomiaru" "zones.noMeasurement": "Brak pomiaru",
"zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Automatyka przejmie sterowanie za {time}.",
"zones.localThermostatResuming": "Czas minął — automatyka przejmuje sterowanie…",
"zones.resumeNow": "Wznów teraz"
} }
} }
+15 -9
View File
@@ -529,7 +529,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, local_thermostat_power: None, active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: 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_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.local_thermostat_power = existing.local_thermostat_power;
zone.local_thermostat_resume_at = existing.local_thermostat_resume_at;
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;
@@ -618,16 +619,19 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|| patch.preset.is_some() || patch.enabled.is_some(); || patch.preset.is_some() || patch.enabled.is_some();
if resume_local_thermostat { if resume_local_thermostat {
zone.local_thermostat_power = None; engine::reset_local_thermostat_override(&mut zone);
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} }
if let Some(power) = patch.power { if let Some(power) = patch.power {
zone.local_thermostat_power = Some(power); zone.local_thermostat_power = Some(power);
zone.local_thermostat_resume_at = if power {
None
} else {
Some(Utc::now() + chrono::Duration::minutes(engine::LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES))
};
if power { zone.enabled = true; } if power { zone.enabled = true; }
// A manually started local thermostat keeps an already selected target/profile until // A manually started local thermostat keeps an already selected target/profile until
// the user chooses Auto or resumes global automation. // it is switched off and the delayed hand-back completes, Auto is selected, or the
// user explicitly resumes automation.
if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) { if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) {
zone.manual_override_until = None; zone.manual_override_until = None;
} }
@@ -680,7 +684,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
} }
if let Some(value) = patch.enabled { if let Some(value) = patch.enabled {
zone.enabled = value; zone.enabled = value;
if !value { zone.local_thermostat_power = None; } if !value { engine::reset_local_thermostat_override(&mut zone); }
} }
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();
@@ -709,6 +713,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
"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, "local_thermostat_power": zone.local_thermostat_power,
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
"device_manual_override_cleared": device_override_cleared "device_manual_override_cleared": device_override_cleared
})); }));
Ok(zone) Ok(zone)
@@ -955,6 +960,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"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, "local_thermostat_power": zone.local_thermostat_power,
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
"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<_>>();
@@ -1009,8 +1015,8 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> { fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
let mut cleared = 0; let mut cleared = 0;
for mut zone in state.db.list_zones()? { for mut zone in state.db.list_zones()? {
if zone.local_thermostat_power.is_none() { continue; } if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() { continue; }
zone.local_thermostat_power = None; engine::reset_local_thermostat_override(&mut zone);
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
state.db.save_zone(&zone)?; state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.broadcast("zone.updated", serde_json::to_value(&zone)?);
+93 -3
View File
@@ -47,8 +47,17 @@ pub fn start(state: AppState) {
tracing::error!(error=?err, "automation cycle failed"); tracing::error!(error=?err, "automation cycle failed");
} }
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
let normal_delay = Duration::from_secs(seconds);
let resume_delay = match next_local_thermostat_resume_delay(&control_state) {
Ok(value) => value,
Err(err) => {
tracing::warn!(error=?err, "cannot calculate local thermostat resume deadline");
None
}
};
let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay);
tokio::select! { tokio::select! {
_ = sleep(Duration::from_secs(seconds)) => {}, _ = sleep(sleep_for) => {},
_ = control_state.zone_control_wakeup.notified() => {}, _ = control_state.zone_control_wakeup.notified() => {},
} }
} }
@@ -561,6 +570,62 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
fields fields
} }
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
let changed = zone.local_thermostat_power.is_some()
|| zone.local_thermostat_resume_at.is_some()
|| zone.manual_preset.is_some()
|| zone.manual_setpoint.is_some()
|| zone.manual_override_until.is_some();
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
changed
}
fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> {
let now = Utc::now();
for zone in zones.iter_mut() {
if zone.local_thermostat_power == Some(false) && zone.local_thermostat_resume_at.is_none() {
// Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases
// had no hand-back deadline, so start one from the first cycle after upgrade.
zone.local_thermostat_resume_at = Some(now.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES));
zone.updated_at = now.clone();
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
}));
continue;
}
let expired = zone.local_thermostat_power == Some(false)
&& zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false);
if !expired { continue; }
reset_local_thermostat_override(zone);
refresh_zone_runtime_target(zone, schedules, house_mode);
zone.updated_at = now.clone();
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.local_thermostat_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
}));
}
Ok(())
}
fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
let now = Utc::now();
Ok(state.db.list_zones()?.into_iter()
.filter(|zone| zone.local_thermostat_power == Some(false))
.filter_map(|zone| zone.local_thermostat_resume_at)
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
.min())
}
pub fn reset_device_manual_override(zone: &mut Zone) -> bool { pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
let changed = zone.device_manual_override let changed = zone.device_manual_override
|| zone.device_manual_override_since.is_some() || zone.device_manual_override_since.is_some()
@@ -961,7 +1026,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
let groups = state.db.list_groups()?; let groups = state.db.list_groups()?;
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let zone_snapshot = state.db.list_zones()?; let mut zone_snapshot = state.db.list_zones()?;
// Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker
// before the house-power early return so the hand-back still happens while the master
// is off; no physical state is restored here, only automation ownership.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
// Outdoor temperature is deliberately optional. Prefer the configured Home // Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the // Assistant entity, but keep the dashboard/assist useful by falling back to the
@@ -1857,6 +1926,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
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, local_thermostat_power: zone.local_thermostat_power,
local_thermostat_resume_at: zone.local_thermostat_resume_at,
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()),
@@ -2258,7 +2328,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, local_thermostat_power: None, manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: 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(),
@@ -2317,6 +2387,26 @@ mod tests {
)); ));
} }
#[test]
fn local_thermostat_resume_clears_only_local_quick_control_state() {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(false);
zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15));
zone.manual_preset = Some("comfort".into());
zone.manual_setpoint = Some(23.0);
zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1));
zone.device_manual_override = true;
assert!(reset_local_thermostat_override(&mut zone));
assert!(zone.local_thermostat_power.is_none());
assert!(zone.local_thermostat_resume_at.is_none());
assert!(zone.manual_preset.is_none());
assert!(zone.manual_setpoint.is_none());
assert!(zone.manual_override_until.is_none());
assert!(zone.device_manual_override);
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
}
#[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");
+6
View File
@@ -349,6 +349,11 @@ pub struct Zone {
/// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off. /// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off.
#[serde(default)] #[serde(default)]
pub local_thermostat_power: Option<bool>, pub local_thermostat_power: Option<bool>,
/// Automatic hand-back deadline after the local quick thermostat is switched OFF.
/// When reached, local ownership and quick profile/setpoint overrides are cleared and
/// the current group/schedule state is evaluated again.
#[serde(default)]
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
/// 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)]
@@ -738,6 +743,7 @@ pub struct ZoneControlPlan {
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 local_thermostat_power: Option<bool>,
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
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>,
+24 -1
View File
@@ -47,6 +47,25 @@ const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mod
const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode); const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode);
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value); const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—'; const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
const localResumeSeconds = value => {
const timestamp = value ? new Date(value).getTime() : NaN;
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null;
};
const formatCountdown = seconds => {
if (!Number.isFinite(seconds)) return '--:--';
const safe = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safe / 60);
const remainder = safe % 60;
return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
};
function updateLocalThermostatCountdowns() {
$$('[data-local-resume-countdown]').forEach(node => {
const remaining = localResumeSeconds(node.dataset.localResumeCountdown);
node.textContent = remaining !== null && remaining > 0
? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)})
: tr('zones.localThermostatResuming');
});
}
const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity; const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity;
function updateConnectionIndicator(status) { function updateConnectionIndicator(status) {
@@ -713,6 +732,7 @@ function zoneCard(zone, detailed = true) {
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 localThermostatPower = zone.local_thermostat_power;
const localThermostatResumeAt = zone.local_thermostat_resume_at || null;
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false; const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
const override = deviceManualOverride const override = deviceManualOverride
? (zone.device_manual_override_until ? (zone.device_manual_override_until
@@ -726,7 +746,9 @@ function zoneCard(zone, detailed = true) {
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-resume="device" 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 : localThermostatOverride
? `<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>` ? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong>${localThermostatPower === false && localThermostatResumeAt
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}">${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}</p>`
: `<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(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
: ''; : '';
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : ''; const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power); const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
@@ -2223,4 +2245,5 @@ async function startApplication() {
if (location.pathname === '/' || location.pathname === `${APP_BASE}/`) updateBrowserUrl('/dashboard', true); if (location.pathname === '/' || location.pathname === `${APP_BASE}/`) updateBrowserUrl('/dashboard', true);
} }
setInterval(updateLocalThermostatCountdowns, 1000);
startApplication().catch(error => console.error('Application startup failed:', error)); startApplication().catch(error => console.error('Application startup failed:', error));
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v0711-clean-local-thermostat'; const CACHE = 'gree-controller-v0712-timed-local-resume';
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')];