This commit is contained in:
Mateusz Gruszczyński
2026-08-25 09:31:15 +02:00
parent 25e1a854e3
commit fd26d6877f
25 changed files with 460 additions and 99 deletions
+10 -2
View File
@@ -1,8 +1,8 @@
# GREE Controller v0.6.4 - build and validation report # GREE Controller v0.6.5 - build and validation report
## Scope ## Scope
Version 0.6.4 includes the notification, reverse-proxy, mobile decimal-input, log filtering and thermostat-copy changes described below, the Rust type-safety hotfix for communication-failure notifications, and a clearer persistent visual state for the selected house climate mode. Version 0.6.5 includes the 0.6.4 changes described below plus the whole-house thermostat semantics, master power control, Home Assistant house controls and LAN response-time reporting documented in this report.
## Implemented changes ## Implemented changes
@@ -122,3 +122,11 @@ Validation performed in the build environment:
- Existing zone climate registry entries are migrated on integration reload to the same scheme based on the zone's current name, fixing stale IDs left after zone renames (for example `climate.jan_2` for a zone currently named Igor). - Existing zone climate registry entries are migrated on integration reload to the same scheme based on the zone's current name, fixing stale IDs left after zone renames (for example `climate.jan_2` for a zone currently named Igor).
- Migration is collision-safe: if the desired `climate.<zone_name>_thermostat` ID is already occupied, the existing entity ID is retained and a warning is logged instead of overwriting another entity. - Migration is collision-safe: if the desired `climate.<zone_name>_thermostat` ID is already occupied, the existing entity ID is retained and a warning is logged instead of overwriting another entity.
## 0.6.5 house control, Home Assistant and LAN response time
- Whole-house thermostat `off` is presented as **Do not control** and no longer continuously powers units down. Directly operated devices remain untouched, while a zone explicitly overridden to Heat/Cool can continue controlling its own unit.
- Added a separate persistent whole-house master-power state. **Turn all off** powers enabled units down and prevents zone/controller automations from restarting them without modifying the selected thermostat mode/profile; **Turn all on** re-enables master power and powers enabled units on.
- Whole-house mode and preset buttons use the same normal active fill as other controls; the obsolete activity-dot styling was removed. Uniform whole-house presets remain visibly selected, while mixed zone profiles show no false global selection.
- Home Assistant now exposes controller-level **Thermostat mode** and **Work profile** select entities plus an **All air conditioners** master-power switch, backed by dedicated restricted HA endpoints.
- Device state stores the latest successful GREE communication round-trip time. **LAN network -> Devices** displays it in milliseconds next to the IP address and protocol.
- PWA cache key advanced to `gree-controller-v065`.
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.6.4" version = "0.6.5"
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.6.4" version = "0.6.5"
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"
+25 -24
View File
@@ -1,38 +1,39 @@
623bd769512ef75f51e9bf7fb685d4d59ec84d7ccc75c826fe72c5538be737c5 ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
58037959419a8de25bcf9095b020bc3ab4c1559b975764f8d24b6359ad063ad6 ./BUILD_REPORT.md c6ea33a11a287f159c6e4c5f19967d8599146692cd576cfe6b8ccacddadd558a ./BUILD_REPORT.md
436fd46220ef759d8204f24bdd94157c965cc39976b0f9baa79cebb367ee5c85 ./Cargo.lock c9673a32218f054f3d3d3b7f89cba3f0aa75f7078558e43ed3adff064e65d209 ./Cargo.lock
53f3f794080df48e11f5726678f7817c225ab64001dd1081d48c5aa780b2a7d0 ./Cargo.toml 735f566fe24949e8a4fae1bebd42d12e91a91cb99a36b114803f0cf0b93212a6 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
be12639245e0929b12d11946b58c1d74f261733724bc4096a9ac68b12df30f24 ./README.md 90fe7b3c18f2ce0d244b8134b73841d3b416d33f7c13d3da107517a124c58d7e ./README.md
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
674f8cf1ab9f1a49188e20b8e3977f1e2b7e9b8ef975315266730c44a55bbb48 ./docs/API.md 6e0e5eb103c4acdaeb04c224fdd49e7ec051c667732d131439a165ac1739b713 ./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
47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md 19e6532f21c59b93c77863e8a8ac3a7d9abaf869f18e30401b7d0975b404b06e ./docs/PROJECT_SPEC.md
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md 7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md 28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
38ac4aec7b971ae7df8db77faf5f9cb9702710ddf9a97def448fbe9e9ff138b1 ./home-assistant/README.md 248570ceff0e020c9d81e8b9e1622ce33f3350f51a04371a9f4fc3f8fa13f62c ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py 001215412d77abd393f34c414ff6d0ebbaf419f140d76037b7f114a7955df6c3 ./home-assistant/custom_components/gree_controller/api.py
abcca32b090e5a5d02574a3a340e97321f9879ae6c94ef12eb1f42d967eae2d5 ./home-assistant/custom_components/gree_controller/climate.py abcca32b090e5a5d02574a3a340e97321f9879ae6c94ef12eb1f42d967eae2d5 ./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
071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py 8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./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
0247514d881e563e45d272dbd10f2363ded1f3fb11b307a6ad9a0566dcff3648 ./home-assistant/custom_components/gree_controller/manifest.json 8666eebd03276d6649104dcf538b79073f4f50a51b1440bcbad0a23c09b0f78b ./home-assistant/custom_components/gree_controller/manifest.json
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py 459233ccefa7827fc1c6887146d8a69e01149a497635fd5e1802c220f418eb28 ./home-assistant/custom_components/gree_controller/select.py
7c23374d1c82e4b361089386ff89265322b80def0f4db9d3fd2c039867033105 ./home-assistant/custom_components/gree_controller/switch.py ec35191e27a018a72cb341914e2abd7fecd25f6b641ea25bebb9e809dd8ecaff ./home-assistant/custom_components/gree_controller/sensor.py
915119bcf400e0da6df777b0a2c13fe33b96daca91f5d8c145acf4831b62098d ./home-assistant/custom_components/gree_controller/switch.py
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
883fc8f52e9d79d41a7c75ca18edccd344ac510e0a36027b76467908ede1c198 ./lang/en.json 997c7a91017b5d51b3e44016d21252b37d302f95ba0fb5802f3d8fd116687d6f ./lang/en.json
4d711b95cb86ad8710ed74bc7f4b49888cb82ac592e18d83b9b7e17def0bc029 ./lang/pl.json 33f6935ad1fe72ea0e6893e230ff95565388410fb0fbfe974b34610d789c5d2b ./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
@@ -45,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
87b985ba7095a3b165928fe6d051acf34c34d2b48ce882b2b2c89c486f3f2ad3 ./src/api.rs f8d1349236819fe0f0253df5269353245f0cacf5b29614ac79c2317c16943636 ./src/api.rs
0f50b30a0088b6ae8289ecf42be1938cbcd95f672d370cff7e5446e7e1f02b83 ./src/config.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs
4a74955d2f5f830bae4758ed078157b3fb1a54c67f338e48ea1f2765efbd980f ./src/engine.rs 8ec14c01036c73f28cdb330d696f0d3573198e7ea4d03ba6bf9cc1d34ec6c7a8 ./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
31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs 31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs
6be14e8e3286c9ce5bd921ccaa39431bb2d3cd882d2945fa8b17b2f00bf5a8fb ./src/models.rs 69cd06183d7492112ed74096cf629b8f4dc073a4b7f15f94b223b299b6244582 ./src/models.rs
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
37bd46ac7c50d1674463eddcf2ee9c85cdbf8fe0829cef13a3f5fe857d3d0f55 ./src/protocol/gree.rs 0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs
f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
c1bcdbbdf45d11aa87aebd82b8553316715e289845563f5af0e493e61243f19c ./web/app.js 53b94be090889a8f709b16b0f88e9e9c732427151de3cb740c1f16144dec37b4 ./web/app.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
002c172392a00006a5128fc4cb881b0361a2d3e76298dbd80c0a013f4e1f9dde ./web/index.html 59e85721942f533243d12491e201dbfedef859dc752b7f0c7c37405705832d2d ./web/index.html
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
80ea1c50c3729adb4dd392b12d73b4247659a8b6b948ee76bd2f5df213eae918 ./web/styles.css a9643913bf5ceba3b0940fff583350f5bb853e7d0421296a9661a4e3e256bde1 ./web/styles.css
03fdeecaa4c68308668c09077e5390286c27d2a8e296367bec6b002fb9b8b3e8 ./web/sw.js e804540ecc928c3f29a8e71fdae68a6e0a36eb0c0698b1ca22cf8f42a888d61c ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+8 -4
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.6.4**. Current version: **0.6.5**.
## Highlights ## Highlights
@@ -46,7 +46,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.6.4.zip unzip gree-controller-v0.6.5.zip
cd gree-controller cd gree-controller
chmod +x scripts/*.sh chmod +x scripts/*.sh
./scripts/dev.sh ./scripts/dev.sh
@@ -221,7 +221,7 @@ Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep now
## Smart thermostat and schedules ## Smart thermostat and schedules
Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. **House Off** remains an explicit hard-off mode. Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. **House Off** is now a no-control state for inherited zones, so manual device operation is not overwritten; whole-house master power on/off is available separately and can authoritatively keep every unit off without changing the selected thermostat mode.
Choose one seasonal **House mode** — Cooling in summer, Heating in winter, or Off. Zones follow the house mode by default, while a zone may explicitly override Heat/Cool when required. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating. Choose one seasonal **House mode** — Cooling in summer, Heating in winter, or Off. Zones follow the house mode by default, while a zone may explicitly override Heat/Cool when required. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating.
@@ -236,7 +236,7 @@ There are two independent HA directions:
1. **HA as an optional sensor source**: the Rust controller can read a selected HA sensor and use it for a zone. 1. **HA as an optional sensor source**: the Rust controller can read a selected HA sensor and use it for a zone.
2. **HA as a client of GREE Controller**: the included custom integration creates `climate` entities whose commands are sent to this Rust application. 2. **HA as a client of GREE Controller**: the included custom integration creates `climate` entities whose commands are sent to this Rust application.
For the HA client connection, open **More -> Home Assistant / Sensors** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/control-plan/zone-control API; they cannot change controller settings or manage other tokens. For the HA client connection, open **More -> Home Assistant / Sensors** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/control-plan/house-control/zone-control API; they cannot change controller settings or manage other tokens.
The outbound HA sensor client also supports local HTTPS endpoints such as `https://192.168.50.25`. If the server uses a self-signed, expired or hostname-mismatched certificate, enable **Home Assistant / Sensors -> Allow invalid/self-signed HTTPS certificate**. This disables certificate/hostname validation only for the controller's outbound Home Assistant sensor client and should only be used on a trusted LAN. The outbound HA sensor client also supports local HTTPS endpoints such as `https://192.168.50.25`. If the server uses a self-signed, expired or hostname-mismatched certificate, enable **Home Assistant / Sensors -> Allow invalid/self-signed HTTPS certificate**. This disables certificate/hostname validation only for the controller's outbound Home Assistant sensor client and should only be used on a trusted LAN.
@@ -294,6 +294,7 @@ POST /api/zones
POST /api/zones/:id/control POST /api/zones/:id/control
POST /api/zones/:id/schedule-template POST /api/zones/:id/schedule-template
POST /api/house/control POST /api/house/control
POST /api/house/power
POST /api/house/preset POST /api/house/preset
GET /api/schedules GET /api/schedules
POST /api/schedules POST /api/schedules
@@ -316,6 +317,9 @@ POST /api/integrations/home-assistant/test
GET /api/integrations/home-assistant/devices GET /api/integrations/home-assistant/devices
POST /api/integrations/home-assistant/devices/{id}/command POST /api/integrations/home-assistant/devices/{id}/command
GET /api/integrations/home-assistant/control-plan GET /api/integrations/home-assistant/control-plan
POST /api/integrations/home-assistant/house/control
POST /api/integrations/home-assistant/house/preset
POST /api/integrations/home-assistant/house/power
POST /api/integrations/home-assistant/zones/{id}/control POST /api/integrations/home-assistant/zones/{id}/control
WS /ws WS /ws
``` ```
+10 -4
View File
@@ -26,6 +26,9 @@ The Home Assistant custom integration uses a restricted API surface:
GET /api/integrations/home-assistant/devices GET /api/integrations/home-assistant/devices
POST /api/integrations/home-assistant/devices/{id}/command POST /api/integrations/home-assistant/devices/{id}/command
GET /api/integrations/home-assistant/control-plan GET /api/integrations/home-assistant/control-plan
POST /api/integrations/home-assistant/house/control
POST /api/integrations/home-assistant/house/preset
POST /api/integrations/home-assistant/house/power
POST /api/integrations/home-assistant/zones/{id}/control POST /api/integrations/home-assistant/zones/{id}/control
``` ```
@@ -63,17 +66,20 @@ Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. Option
## Smart thermostat / house control ## Smart thermostat / house control
The controller separates the seasonal house mode from per-zone profiles. Normal operation uses setpoint modulation: units remain powered while the controller moves the device target between an active and satisfied setpoint. `off` is the explicit hard-off house mode. The controller separates the seasonal house mode from per-zone profiles. Normal operation uses setpoint modulation: units remain powered while the controller moves the device target between an active and satisfied setpoint. House mode `off` means **do not control** inherited zones: it does not power a manually operated unit down. A zone explicitly switched to `cool` or `heat` remains independent and can continue to run.
```bash ```bash
curl -X POST "$BASE/api/house/control" -H "$AUTH" -H 'Content-Type: application/json' \ curl -X POST "$BASE/api/house/control" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"mode":"cool"}' -d '{"mode":"cool"}'
curl -X POST "$BASE/api/house/power" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"power":false}'
curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/json' \ curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"preset":"sleep"}' -d '{"preset":"sleep"}'
``` ```
House modes: `cool`, `heat`, `off`. House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates temporary per-zone overrides that expire at each zone's next schedule boundary. House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates temporary per-zone overrides that expire at each zone's next schedule boundary. `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.
A zone stores separate profile temperatures for both seasons: A zone stores separate profile temperatures for both seasons:
@@ -167,13 +173,13 @@ This setting is opt-in and applies only to the controller's outbound Home Assist
## Current control plan ## Current control plan
`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/strategy, each zone's effective `mode`, `configured_mode`, `inherit_house_mode`, preset/current and target temperatures, current schedule, manual override expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events. `GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode, uniform house preset (or `null` for mixed zone profiles), whole-house master-power state, strategy, each zone's effective `mode`, `configured_mode`, `inherit_house_mode`, preset/current and target temperatures, current schedule, manual override expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events.
```bash ```bash
curl "$BASE/api/control-plan" -H "$AUTH" curl "$BASE/api/control-plan" -H "$AUTH"
``` ```
The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may change a zone target/preset/mode/enabled state through: The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also change house mode, house preset and whole-house master power through the dedicated `/api/integrations/home-assistant/house/*` endpoints. HA may change a zone target/preset/mode/enabled state through:
```bash ```bash
curl -X POST "$BASE/api/integrations/home-assistant/zones/ZONE_ID/control" \ curl -X POST "$BASE/api/integrations/home-assistant/zones/ZONE_ID/control" \
+1 -1
View File
@@ -68,7 +68,7 @@ Each device stores:
## Smart thermostat, zones and schedules ## Smart thermostat, zones and schedules
The primary user model is a fast thermostat, not raw device automation. A global **House mode** selects the season (`cool`, `heat`, `off`). Zones follow it by default and can optionally override Heat/Cool. Global Off is authoritative and powers controlled units down. The primary user model is a fast thermostat, not raw device automation. A global **House mode** selects the season (`cool`, `heat`, `off`). Zones follow it by default and can optionally override Heat/Cool. House `off` is a **do not control** state for inherited zones: manual device operation is left untouched, while a zone explicitly switched to Heat/Cool can operate independently. Whole-house power on/off is a separate master state. Master Off is authoritative for zones and controller automations but does not modify the selected house thermostat mode.
During normal heating/cooling the controller minimizes unit power cycling. It keeps the indoor unit powered and uses **setpoint modulation**: During normal heating/cooling the controller minimizes unit power cycling. It keeps the indoor unit powered and uses **setpoint modulation**:
+9
View File
@@ -126,3 +126,12 @@ From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`,
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created. When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
## Whole-house controls
The integration also exposes three controller-level entities on the **GREE Controller** device:
- **Thermostat mode** (`select`) — Cooling, Heating or **Do not control**. Do not control pauses house-level thermostat commands but leaves direct device control and explicit per-zone Heat/Cool overrides untouched.
- **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away. If zones have mixed manual profiles, the select has no single current option until a whole-house profile is chosen again.
- **All air conditioners** (`switch`) — separate master power. Turning it off powers every enabled unit down and prevents zones/controller automations from restarting them. Turning it on powers all enabled units on again without changing the selected thermostat mode or profile.
These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints.
@@ -82,6 +82,39 @@ class GreeControllerClient:
raise GreeControllerApiError("Controller returned an invalid control plan payload") raise GreeControllerApiError("Controller returned an invalid control plan payload")
return data return data
async def house_control(self, mode: str) -> dict[str, Any]:
"""Change the whole-house thermostat mode."""
data = await self._request(
"POST",
"/api/integrations/home-assistant/house/control",
json={"mode": mode},
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid house control payload")
return data
async def house_preset(self, preset: str) -> dict[str, Any]:
"""Change the whole-house work profile."""
data = await self._request(
"POST",
"/api/integrations/home-assistant/house/preset",
json={"preset": preset},
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid house preset payload")
return data
async def house_power(self, power: bool) -> dict[str, Any]:
"""Turn all enabled air conditioners on or off."""
data = await self._request(
"POST",
"/api/integrations/home-assistant/house/power",
json={"power": power},
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid house power payload")
return data
async def zone_control(self, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]: async def zone_control(self, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Change a zone thermostat override through the controller.""" """Change a zone thermostat override through the controller."""
data = await self._request( data = await self._request(
@@ -3,7 +3,7 @@
from homeassistant.const import Platform from homeassistant.const import Platform
DOMAIN = "gree_controller" DOMAIN = "gree_controller"
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.NUMBER, Platform.SWITCH] PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH]
CONF_URL = "url" CONF_URL = "url"
CONF_TOKEN = "token" CONF_TOKEN = "token"
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.6.4", "version": "0.6.5",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
@@ -0,0 +1,105 @@
"""Whole-house thermostat controls for GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
HOUSE_MODE_LABELS = {
"off": "Do not control",
"cool": "Cooling",
"heat": "Heating",
}
HOUSE_MODE_VALUES = {label: value for value, label in HOUSE_MODE_LABELS.items()}
HOUSE_PRESET_LABELS = {
"auto": "Auto schedule",
"comfort": "Comfort",
"sleep": "Sleep",
"away": "Away",
}
HOUSE_PRESET_VALUES = {label: value for value, label in HOUSE_PRESET_LABELS.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create whole-house thermostat mode and work-profile selectors."""
runtime: GreeControllerRuntimeData = entry.runtime_data
async_add_entities(
[
GreeControllerHouseModeSelect(runtime.coordinator),
GreeControllerHousePresetSelect(runtime.coordinator),
]
)
class _GreeControllerHouseSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Base for controller-level selectors."""
_attr_has_entity_name = True
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, "controller")},
name="GREE Controller",
manufacturer="GREE Controller",
model="Local controller",
)
class GreeControllerHouseModeSelect(_GreeControllerHouseSelect):
"""Select the smart thermostat house mode."""
_attr_name = "Thermostat mode"
_attr_unique_id = "house-thermostat-mode"
_attr_options = list(HOUSE_MODE_VALUES)
@property
def current_option(self) -> str | None:
value = str(self.coordinator.plan.get("house_mode") or "")
return HOUSE_MODE_LABELS.get(value)
async def async_select_option(self, option: str) -> None:
value = HOUSE_MODE_VALUES.get(option)
if value is None:
return
await self.coordinator.client.house_control(value)
await self.coordinator.async_request_refresh()
class GreeControllerHousePresetSelect(_GreeControllerHouseSelect):
"""Select the whole-house thermostat work profile."""
_attr_name = "Work profile"
_attr_unique_id = "house-work-profile"
_attr_options = list(HOUSE_PRESET_VALUES)
@property
def current_option(self) -> str | None:
value = self.coordinator.plan.get("house_preset")
return HOUSE_PRESET_LABELS.get(str(value)) if value is not None else None
@property
def extra_state_attributes(self) -> dict[str, Any]:
return {"mixed_zone_profiles": self.coordinator.plan.get("house_preset") is None}
async def async_select_option(self, option: str) -> None:
value = HOUSE_PRESET_VALUES.get(option)
if value is None:
return
await self.coordinator.client.house_preset(value)
await self.coordinator.async_request_refresh()
@@ -59,6 +59,8 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
"generated_at": plan.get("generated_at"), "generated_at": plan.get("generated_at"),
"outdoor_temperature": plan.get("outdoor_temperature"), "outdoor_temperature": plan.get("outdoor_temperature"),
"control_strategy": plan.get("control_strategy"), "control_strategy": plan.get("control_strategy"),
"work_profile": plan.get("house_preset"),
"master_power": bool(plan.get("house_power", False)),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones), "enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones), "demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones),
"next_events": plan.get("next_events", []), "next_events": plan.get("next_events", []),
@@ -35,9 +35,12 @@ async def async_setup_entry(
"""Create one enabled switch per controller zone.""" """Create one enabled switch per controller zone."""
runtime: GreeControllerRuntimeData = entry.runtime_data runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SwitchEntity] = [ entities: list[SwitchEntity] = [
GreeControllerHousePowerSwitch(runtime.coordinator),
*[
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"])) GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
for zone in runtime.coordinator.plan.get("zones", []) for zone in runtime.coordinator.plan.get("zones", [])
if zone.get("zone_id") if zone.get("zone_id")
],
] ]
for device_id, device in runtime.coordinator.data.items(): for device_id, device in runtime.coordinator.data.items():
for field, support_field, name in DEVICE_FEATURE_SWITCHES: for field, support_field, name in DEVICE_FEATURE_SWITCHES:
@@ -46,6 +49,35 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Master power action for all enabled indoor units."""
_attr_has_entity_name = True
_attr_name = "All air conditioners"
_attr_unique_id = "house-power-all"
@property
def is_on(self) -> bool:
return bool(self.coordinator.plan.get("house_power", False))
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, "controller")},
name="GREE Controller",
manufacturer="GREE Controller",
model="Local controller",
)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.client.house_power(True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.house_power(False)
await self.coordinator.async_request_refresh()
class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity): class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Enable or disable a controller thermostat zone.""" """Enable or disable a controller thermostat zone."""
+13 -3
View File
@@ -244,7 +244,7 @@
"mode.off": "Off", "mode.off": "Off",
"house.seasonMode": "Home climate mode", "house.seasonMode": "Home climate mode",
"house.smartThermostat": "Smart thermostat", "house.smartThermostat": "Smart thermostat",
"house.setpointStrategy": "Keeps units running and modulates their target temperature instead of repeatedly switching power.", "house.setpointStrategy": "Cooling or heating controls zones automatically. “Do not control” leaves devices and manually activated zones untouched by the house thermostat.",
"house.outdoor": "Outdoor", "house.outdoor": "Outdoor",
"house.modeUpdated": "Home climate mode updated", "house.modeUpdated": "Home climate mode updated",
"zones.modePolicy": "Mode policy", "zones.modePolicy": "Mode policy",
@@ -437,7 +437,7 @@
"simulation.stateDemand": "Demand", "simulation.stateDemand": "Demand",
"simulation.stateSatisfied": "Satisfied", "simulation.stateSatisfied": "Satisfied",
"simulation.reasonDisabled": "Zone is disabled, so the thermostat does not send control commands.", "simulation.reasonDisabled": "Zone is disabled, so the thermostat does not send control commands.",
"simulation.reasonHouseOff": "The global house mode is off, so this zone stays inactive.", "simulation.reasonHouseOff": "House “Do not control” leaves this zone under manual control and sends no commands to the air conditioner.",
"simulation.reasonWaiting": "The simulator is waiting for a valid room temperature and target.", "simulation.reasonWaiting": "The simulator is waiting for a valid room temperature and target.",
"simulation.reasonDemand": "Room needs {mode}. Thermostat compares the reading with the target {target}°C using hysteresis {hysteresis}°C.", "simulation.reasonDemand": "Room needs {mode}. Thermostat compares the reading with the target {target}°C using hysteresis {hysteresis}°C.",
"simulation.reasonSatisfied": "Target is reached. The thermostat moves the unit into standby around {standby}°C to keep the room quiet and stable.", "simulation.reasonSatisfied": "Target is reached. The thermostat moves the unit into standby around {standby}°C to keep the room quiet and stable.",
@@ -530,6 +530,16 @@
"logs.integrations": "Integrations", "logs.integrations": "Integrations",
"logs.systemOther": "System/other", "logs.systemOther": "System/other",
"zones.copyDifferent": "Choose a different thermostat profile", "zones.copyDifferent": "Choose a different thermostat profile",
"zones.copiedFrom": "Thermostat settings copied from {name}" "zones.copiedFrom": "Thermostat settings copied from {name}",
"house.noControl": "Do not control",
"house.powerAllEyebrow": "Global power",
"house.powerAllTitle": "All air conditioners",
"house.powerAllHint": "Separate master power for all enabled units. “Turn all off” prevents zones and automations from restarting them without changing the selected thermostat mode.",
"house.powerOnAll": "Turn all on",
"house.powerOffAll": "Turn all off",
"house.powerOnDone": "All air conditioners turned on",
"house.powerOffDone": "All air conditioners turned off",
"house.powerPartial": "Could not change power on {count} devices",
"preset.manual": "Manual"
} }
} }
+13 -3
View File
@@ -244,7 +244,7 @@
"mode.off": "Wyłączone", "mode.off": "Wyłączone",
"house.seasonMode": "Tryb klimatu domu", "house.seasonMode": "Tryb klimatu domu",
"house.smartThermostat": "Inteligentny termostat", "house.smartThermostat": "Inteligentny termostat",
"house.setpointStrategy": "Utrzymuje jednostki włączone i steruje temperaturą zadaną zamiast często przełączać zasilanie.", "house.setpointStrategy": "Chłodzenie lub grzanie steruje strefami automatycznie. „Nie steruj” pozostawia urządzenia i ręcznie uruchomione strefy bez ingerencji termostatu domu.",
"house.outdoor": "Na zewnątrz", "house.outdoor": "Na zewnątrz",
"house.modeUpdated": "Zmieniono tryb klimatu domu", "house.modeUpdated": "Zmieniono tryb klimatu domu",
"zones.modePolicy": "Polityka trybu", "zones.modePolicy": "Polityka trybu",
@@ -437,7 +437,7 @@
"simulation.stateDemand": "Zapotrzebowanie", "simulation.stateDemand": "Zapotrzebowanie",
"simulation.stateSatisfied": "Osiągnięto cel", "simulation.stateSatisfied": "Osiągnięto cel",
"simulation.reasonDisabled": "Strefa jest wyłączona, więc termostat nie wysyła komend sterujących.", "simulation.reasonDisabled": "Strefa jest wyłączona, więc termostat nie wysyła komend sterujących.",
"simulation.reasonHouseOff": "Globalny tryb domu jest wyłączony, więc ta strefa pozostaje nieaktywna.", "simulation.reasonHouseOff": "Tryb domu „Nie steruj” pozostawia tę strefę pod sterowaniem ręcznym i nie wysyła poleceń do klimatyzatora.",
"simulation.reasonWaiting": "Symulator czeka na poprawny odczyt temperatury pokojowej i cel.", "simulation.reasonWaiting": "Symulator czeka na poprawny odczyt temperatury pokojowej i cel.",
"simulation.reasonDemand": "Pomieszczenie potrzebuje trybu {mode}. Termostat porównuje odczyt z celem {target}°C i histerezą {hysteresis}°C.", "simulation.reasonDemand": "Pomieszczenie potrzebuje trybu {mode}. Termostat porównuje odczyt z celem {target}°C i histerezą {hysteresis}°C.",
"simulation.reasonSatisfied": "Cel został osiągnięty. Termostat przełącza jednostkę w podtrzymanie około {standby}°C, aby utrzymać ciszę i stabilność.", "simulation.reasonSatisfied": "Cel został osiągnięty. Termostat przełącza jednostkę w podtrzymanie około {standby}°C, aby utrzymać ciszę i stabilność.",
@@ -530,6 +530,16 @@
"logs.integrations": "Integracje", "logs.integrations": "Integracje",
"logs.systemOther": "System/inne", "logs.systemOther": "System/inne",
"zones.copyDifferent": "Wybierz inny profil termostatu", "zones.copyDifferent": "Wybierz inny profil termostatu",
"zones.copiedFrom": "Skopiowano ustawienia termostatu z: {name}" "zones.copiedFrom": "Skopiowano ustawienia termostatu z: {name}",
"house.noControl": "Nie steruj",
"house.powerAllEyebrow": "Zasilanie globalne",
"house.powerAllTitle": "Wszystkie klimatyzatory",
"house.powerAllHint": "Osobne nadrzędne zasilanie wszystkich aktywnych jednostek. „Wyłącz wszystkie” blokuje ponowne uruchamianie przez strefy i automatyzacje, ale nie zmienia wybranego trybu termostatu.",
"house.powerOnAll": "Włącz wszystkie",
"house.powerOffAll": "Wyłącz wszystkie",
"house.powerOnDone": "Włączono wszystkie klimatyzatory",
"house.powerOffDone": "Wyłączono wszystkie klimatyzatory",
"house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń",
"preset.manual": "Ręcznie"
} }
} }
+56
View File
@@ -52,6 +52,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/zones/:id/control", post(update_zone_control)) .route("/api/zones/:id/control", post(update_zone_control))
.route("/api/zones/:id/schedule-template", post(apply_schedule_template)) .route("/api/zones/:id/schedule-template", post(apply_schedule_template))
.route("/api/house/control", post(update_house_control)) .route("/api/house/control", post(update_house_control))
.route("/api/house/power", post(update_house_power))
.route("/api/house/preset", post(update_house_preset)) .route("/api/house/preset", post(update_house_preset))
.route("/api/schedules", get(list_schedules).post(create_schedule)) .route("/api/schedules", get(list_schedules).post(create_schedule))
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule)) .route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
@@ -76,6 +77,9 @@ pub fn router(state: AppState) -> Router {
.route("/api/integrations/home-assistant/devices", get(list_devices)) .route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device)) .route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan)) .route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/house/control", post(update_house_control))
.route("/api/integrations/home-assistant/house/preset", post(update_house_preset))
.route("/api/integrations/home-assistant/house/power", post(update_house_power))
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control)) .route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
@@ -318,6 +322,7 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
outdoor_temperature: None, outdoor_temperature: None,
temperature_sensor_offset: None, temperature_sensor_offset: None,
online: input.simulated, online: input.simulated,
response_time_ms: if input.simulated { Some(0) } else { None },
last_seen: if input.simulated { Some(now) } else { None }, last_seen: if input.simulated { Some(now) } else { None },
last_error: None, last_error: None,
communication_failures: 0, communication_failures: 0,
@@ -602,6 +607,56 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
Ok(Json(payload)) Ok(Json(payload))
} }
#[derive(Debug, Deserialize)]
struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
// Whole-house power is independent from the thermostat mode. Turning it off is
// authoritative, while house mode `off` remains a separate "do not control" state.
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
settings.house_power_enabled = input.power;
state.db.save_runtime_settings(&settings)?;
let payload = public_settings(&settings);
state.broadcast("settings.updated", payload);
}
}
let mut failed = Vec::new();
for device in state.db.list_devices()? {
if !device.enabled || device.power == input.power { continue; }
let command = DeviceCommand { power: Some(input.power), ..Default::default() };
if let Err(err) = engine::send_command(&state, &device.id, command).await {
state.log("error", "house.power_all_error", &err.to_string(), json!({
"device_id": device.id,
"device_name": device.name,
"power": input.power,
}));
failed.push(json!({
"device_id": device.id,
"device_name": device.name,
"error": err.to_string(),
}));
}
}
let devices = state.db.list_devices()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled devices powered on" } else { "Whole-house power disabled; all enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
Ok(Json(json!({
"power": input.power,
"devices": devices,
"settings": settings_payload,
"failed": failed,
})))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HousePresetPatch { preset: String } struct HousePresetPatch { preset: String }
@@ -1379,6 +1434,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"discovery_timeout_ms": settings.discovery_timeout_ms, "discovery_timeout_ms": settings.discovery_timeout_ms,
"discovery_broadcast": settings.discovery_broadcast, "discovery_broadcast": settings.discovery_broadcast,
"house_mode": settings.house_mode, "house_mode": settings.house_mode,
"house_power_enabled": settings.house_power_enabled,
"control_strategy": settings.control_strategy, "control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled, "outdoor_assist_enabled": settings.outdoor_assist_enabled,
"history_retention_days": settings.history_retention_days, "history_retention_days": settings.history_retention_days,
+1
View File
@@ -53,6 +53,7 @@ impl Config {
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000), discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
discovery_broadcast: self.discovery_broadcast.clone(), discovery_broadcast: self.discovery_broadcast.clone(),
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()), house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
house_power_enabled: true,
control_strategy: "setpoint".into(), control_strategy: "setpoint".into(),
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true), outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
+53 -21
View File
@@ -1,4 +1,4 @@
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
use serde_json::json; use serde_json::json;
@@ -107,11 +107,13 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
let command = if device.online { command.changed_from(&device) } else { command }; let command = if device.online { command.changed_from(&device) } else { command };
if command.is_empty() { return Ok(device); } if command.is_empty() { return Ok(device); }
let suppress_beep = state.settings.read().await.suppress_device_beep; let suppress_beep = state.settings.read().await.suppress_device_beep;
let response_started = Instant::now();
let mut applied_command = command.clone(); let mut applied_command = command.clone();
if device.simulated { if device.simulated {
applied_command.apply(&mut device); applied_command.apply(&mut device);
device.online = true; device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now()); device.last_seen = Some(Utc::now());
device.last_error = None; device.last_error = None;
state.db.save_device(&device)?; state.db.save_device(&device)?;
@@ -159,6 +161,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
applied_command.apply(&mut device); applied_command.apply(&mut device);
device.online = true; device.online = true;
device.communication_failures = 0; device.communication_failures = 0;
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
device.last_seen = Some(Utc::now()); device.last_seen = Some(Utc::now());
device.last_error = None; device.last_error = None;
state.db.save_device(&device)?; state.db.save_device(&device)?;
@@ -199,6 +202,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
return; return;
} }
let previous_failures = device.communication_failures; let previous_failures = device.communication_failures;
let response_started = Instant::now();
if device.key.as_deref().unwrap_or_default().is_empty() { if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await { match state.gree.bind(device).await {
Ok(bound) => { Ok(bound) => {
@@ -227,6 +231,9 @@ async fn poll_device(state: &AppState, device: &mut Device) {
Err(_) => record_poll_failure(device, &first_err.to_string()), Err(_) => record_poll_failure(device, &first_err.to_string()),
} }
} }
if device.communication_failures == 0 && device.online {
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
}
log_poll_health_transition(state, device, previous_failures).await; log_poll_health_transition(state, device, previous_failures).await;
} }
@@ -268,6 +275,7 @@ fn simulate_tick(device: &mut Device) {
// Correct rounding for outdoor temperature without accumulating precision noise. // Correct rounding for outdoor temperature without accumulating precision noise.
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0); device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
device.online = true; device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now()); device.last_seen = Some(Utc::now());
device.last_error = None; device.last_error = None;
device.updated_at = Utc::now(); device.updated_at = Utc::now();
@@ -369,6 +377,16 @@ async fn control_zones(state: &AppState) -> Result<()> {
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time()); let night_active = night_mode_active(&settings.night_mode, Local::now().time());
if !settings.house_power_enabled {
for device in &device_snapshot {
if !device.enabled || !device.power { continue; }
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
state.log("error", "house.master_power_error", &err.to_string(), json!({"device_id": device.id}));
}
}
return Ok(());
}
for mut zone in state.db.list_zones()? { for mut zone in state.db.list_zones()? {
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None; zone.manual_preset = None;
@@ -381,9 +399,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue; continue;
}; };
let effective_mode = if settings.house_mode == "off" { // House "off" means the smart thermostat does not control inherited zones.
"off" // A zone explicitly switched to heat/cool remains independent and may still run.
} else if zone.inherit_house_mode { let effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str() settings.house_mode.as_str()
} else { } else {
zone.mode.as_str() zone.mode.as_str()
@@ -444,18 +462,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue; continue;
} }
// Global Off is the only normal path that intentionally powers units down. // House "off" is a no-control state, not a power-off command. Keep polling and
// publishing the zone, but never overwrite manual device state while it follows
// the house mode. Explicit per-zone heat/cool bypasses this branch above.
if effective_mode == "off" { if effective_mode == "off" {
zone.active_preset = "off".into();
zone.effective_setpoint = None; zone.effective_setpoint = None;
zone.device_setpoint = None; zone.device_setpoint = None;
zone.demand = false; zone.demand = false;
if device.power { zone.demand_since = None;
match send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { zone.target_alerted_at = None;
Ok(_) => zone.last_action_at = Some(Utc::now()),
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
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)?);
@@ -603,6 +618,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
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)?);
} }
Ok(()) Ok(())
} }
@@ -915,28 +931,41 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let house_preset = zones.first().and_then(|first| {
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
.then(|| first_preset.to_string())
});
let house_power = settings.house_power_enabled;
let now = Local::now(); let now = Local::now();
let night_active = night_mode_active(&settings.night_mode, now.time()); let night_active = night_mode_active(&settings.night_mode, now.time());
let mut zones_out = Vec::new(); let mut zones_out = Vec::new();
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2); let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
for zone in state.db.list_zones()? { for zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id); let device = devices.iter().find(|item| item.id == zone.device_id);
let effective_mode = if settings.house_mode == "off" { let effective_mode = if zone.inherit_house_mode {
"off"
} else if zone.inherit_house_mode {
settings.house_mode.as_str() settings.house_mode.as_str()
} else { } else {
zone.mode.as_str() zone.mode.as_str()
}; };
let active = active_schedule_for_zone(&zone, &schedules, now); let active = if effective_mode == "off" {
None
} else {
active_schedule_for_zone(&zone, &schedules, now)
};
let (preset, target) = if effective_mode == "off" { let (preset, target) = if effective_mode == "off" {
("off".to_string(), None) ("manual".to_string(), None)
} else { } else {
let (preset, target) = resolve_zone_target(&zone, active, effective_mode); let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
(preset, Some(target)) (preset, Some(target))
}; };
let next_events = next_schedule_events(&zone, &schedules, effective_mode, now, 8); let next_events = if effective_mode == "off" {
Vec::new()
} else {
next_schedule_events(&zone, &schedules, effective_mode, now, 8)
};
for event in next_events.iter().take(2) { for event in next_events.iter().take(2) {
let mut event = event.clone(); let mut event = event.clone();
event.label = format!("{}: {}", zone.name, event.label); event.label = format!("{}: {}", zone.name, event.label);
@@ -951,11 +980,11 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
mode: effective_mode.to_string(), mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(), configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode, inherit_house_mode: zone.inherit_house_mode,
preset: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() }, preset: if effective_mode == "off" { "manual".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature, current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) }, target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)), device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
demand: zone.enabled && effective_mode != "off" && zone.demand, demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && 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,
current_schedule_id: active.map(|item| item.id.clone()), current_schedule_id: active.map(|item| item.id.clone()),
@@ -996,6 +1025,8 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
Ok(ControlPlan { Ok(ControlPlan {
generated_at: Utc::now(), generated_at: Utc::now(),
house_mode: settings.house_mode, house_mode: settings.house_mode,
house_preset,
house_power,
outdoor_temperature: *state.outdoor_temperature.read().await, outdoor_temperature: *state.outdoor_temperature.read().await,
control_strategy: settings.control_strategy, control_strategy: settings.control_strategy,
night_mode_active: night_active, night_mode_active: night_active,
@@ -1084,6 +1115,7 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
} }
async fn run_automations(state: &AppState) -> Result<()> { async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
for mut item in state.db.list_automations()? { for mut item in state.db.list_automations()? {
if !item.enabled || !automation_ready(&item) { continue; } if !item.enabled || !automation_ready(&item) { continue; }
+12 -1
View File
@@ -108,6 +108,9 @@ pub struct Device {
pub temperature_sensor_offset: Option<bool>, pub temperature_sensor_offset: Option<bool>,
#[serde(default)] #[serde(default)]
pub online: bool, pub online: bool,
/// Round-trip time of the latest successful controller communication.
#[serde(default)]
pub response_time_ms: Option<u64>,
#[serde(default)] #[serde(default)]
pub last_seen: Option<DateTime<Utc>>, pub last_seen: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
@@ -158,6 +161,7 @@ impl Device {
outdoor_temperature: Some(30.0), outdoor_temperature: Some(30.0),
temperature_sensor_offset: Some(false), temperature_sensor_offset: Some(false),
online: true, online: true,
response_time_ms: Some(0),
last_seen: Some(now), last_seen: Some(now),
last_error: None, last_error: None,
communication_failures: 0, communication_failures: 0,
@@ -667,6 +671,10 @@ pub struct AutomationPlanRule {
pub struct ControlPlan { pub struct ControlPlan {
pub generated_at: DateTime<Utc>, pub generated_at: DateTime<Utc>,
pub house_mode: String, pub house_mode: String,
/// Uniform whole-house preset when every zone uses the same override; None for a mixed state.
pub house_preset: Option<String>,
/// Whole-house master power state.
pub house_power: bool,
pub outdoor_temperature: Option<f64>, pub outdoor_temperature: Option<f64>,
pub control_strategy: String, pub control_strategy: String,
pub night_mode_active: bool, pub night_mode_active: bool,
@@ -686,9 +694,12 @@ pub struct RuntimeSettings {
pub zone_interval_seconds: u64, pub zone_interval_seconds: u64,
pub discovery_timeout_ms: u64, pub discovery_timeout_ms: u64,
pub discovery_broadcast: String, pub discovery_broadcast: String,
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off. /// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control.
#[serde(default = "default_house_mode")] #[serde(default = "default_house_mode")]
pub house_mode: String, pub house_mode: String,
/// Whole-house master power. False is authoritative and suppresses zone/automation restarts.
#[serde(default = "default_true")]
pub house_power_enabled: bool,
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature. /// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
#[serde(default = "default_control_strategy")] #[serde(default = "default_control_strategy")]
pub control_strategy: String, pub control_strategy: String,
+1
View File
@@ -269,6 +269,7 @@ impl GreeClient {
outdoor_temperature: None, outdoor_temperature: None,
temperature_sensor_offset: None, temperature_sensor_offset: None,
online: true, online: true,
response_time_ms: None,
last_seen: Some(now), last_seen: Some(now),
last_error: None, last_error: None,
communication_failures: 0, communication_failures: 0,
+54 -25
View File
@@ -43,6 +43,7 @@ const tr = (key, params = {}) => {
const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value); const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value);
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`); const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${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 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;
@@ -246,14 +247,20 @@ function renderSummary() {
function renderHouseClimate() { function renderHouseClimate() {
const node = $('#houseClimate'); if (!node || !app.settings) return; const node = $('#houseClimate'); if (!node || !app.settings) return;
const mode = app.settings.house_mode || 'cool'; const mode = app.settings.house_mode || 'cool';
const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto');
const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null;
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature); const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
const powerEnabled = app.settings.house_power_enabled !== false;
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
if (powerOn) { powerOn.classList.toggle('active', powerEnabled); powerOn.setAttribute('aria-pressed', String(powerEnabled)); }
if (powerOff) { powerOff.classList.toggle('active', !powerEnabled); powerOff.setAttribute('aria-pressed', String(!powerEnabled)); }
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div> node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div>
<div class="house-mode-row"> <div class="house-mode-row">
<button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool" aria-pressed="${mode==='cool'}"><span class="mode-indicator"></span>${esc(tr('mode.cool'))}</button> <button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool" aria-pressed="${mode==='cool'}">${esc(tr('mode.cool'))}</button>
<button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat" aria-pressed="${mode==='heat'}"><span class="mode-indicator"></span>${esc(tr('mode.heat'))}</button> <button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat" aria-pressed="${mode==='heat'}">${esc(tr('mode.heat'))}</button>
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off" aria-pressed="${mode==='off'}"><span class="mode-indicator"></span>${esc(tr('mode.off'))}</button> <button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off" aria-pressed="${mode==='off'}">${esc(tr('house.noControl'))}</button>
</div> </div>
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button data-action="house-preset" data-value="${p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`; <div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button class="${preset===p?'active':''}" data-action="house-preset" data-value="${p}" aria-pressed="${preset===p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`;
} }
function planEventMarkup(event) { function planEventMarkup(event) {
@@ -276,11 +283,11 @@ function renderControlPlan() {
return; return;
} }
const houseEvents = (plan.next_events || []).slice(0, 5); const houseEvents = (plan.next_events || []).slice(0, 5);
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(modeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:(plan.zones || []).filter(zone=>zone.enabled).length, demand:(plan.zones || []).filter(zone=>zone.enabled && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`; const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:(plan.zones || []).filter(zone=>zone.enabled).length, demand:(plan.zones || []).filter(zone=>zone.enabled && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
const zones = (plan.zones || []).map(zone => { const zones = (plan.zones || []).map(zone => {
const events = (zone.next_events || []).slice(0, 3); const events = (zone.next_events || []).slice(0, 3);
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1); const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
return `<article class="panel plan-card ${zone.enabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(zone.device_name || tr('common.noDevice'))}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'))}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(modeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`; return `<article class="panel plan-card ${zone.enabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(zone.device_name || tr('common.noDevice'))}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'))}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
}).join(''); }).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled); const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `<article class="panel plan-card"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', {count:rules.length}))}</h3></div></div><ul class="plan-events">${rules.slice(0,5).map(rule=>`<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)}${esc(rule.action_device_name || '')}</span></li>`).join('')}</ul></article>` : ''; const ruleCard = rules.length ? `<article class="panel plan-card"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', {count:rules.length}))}</h3></div></div><ul class="plan-events">${rules.slice(0,5).map(rule=>`<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)}${esc(rule.action_device_name || '')}</span></li>`).join('')}</ul></article>` : '';
@@ -330,7 +337,7 @@ function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
function simulationStatusInfo(status) { function simulationStatusInfo(status) {
const map = { const map = {
disabled: {label: tr('common.disabled'), badge: ''}, disabled: {label: tr('common.disabled'), badge: ''},
off: {label: tr('mode.off'), badge: ''}, off: {label: tr('house.noControl'), badge: ''},
waiting: {label: tr('simulation.waitingForData'), badge: ''}, waiting: {label: tr('simulation.waitingForData'), badge: ''},
demand: {label: tr('simulation.stateDemand'), badge: 'active'}, demand: {label: tr('simulation.stateDemand'), badge: 'active'},
satisfied: {label: tr('simulation.stateSatisfied'), badge: ''}, satisfied: {label: tr('simulation.stateSatisfied'), badge: ''},
@@ -342,7 +349,7 @@ function buildZoneSimulation(zone, planZone) {
const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id)); const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id));
const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off'; const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off';
const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature); const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature);
const target = simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint); const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint);
const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature); const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature);
const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1); const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1);
const half = hysteresis / 2; const half = hysteresis / 2;
@@ -370,15 +377,20 @@ function buildZoneSimulation(zone, planZone) {
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget); const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
const nightActive = !!app.controlPlan?.night_mode_active; const nightActive = !!app.controlPlan?.night_mode_active;
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5); const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
let fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed); let fanSpeed = simulationNumeric(device?.fan_speed);
if (nightActive) { if (mode !== 'off') {
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan); fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed;
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan; if (nightActive) {
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
}
} }
const quiet = nightActive && app.settings?.night_mode?.force_quiet const quiet = mode === 'off'
? tr('simulation.quietIfSupported') ? tr('simulation.manual')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual')); : nightActive && app.settings?.night_mode?.force_quiet
const nativeSleep = nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true; ? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZone?.enabled === false const reasoning = !zone?.enabled || planZone?.enabled === false
? tr('simulation.reasonDisabled') ? tr('simulation.reasonDisabled')
: mode === 'off' : mode === 'off'
@@ -416,7 +428,7 @@ function renderSimulationPage() {
const demandingZones = enabledZones.filter(zone => zone.demand); const demandingZones = enabledZones.filter(zone => zone.demand);
const nightOn = !!plan.night_mode_active; const nightOn = !!plan.night_mode_active;
summaryHost.innerHTML = [ summaryHost.innerHTML = [
{label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})}, {label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
{label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`}, {label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`},
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})}, {label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled).length}`}, {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled).length}`},
@@ -436,7 +448,7 @@ function renderSimulationPage() {
const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `<article class="flow-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-port out"></i></article>`; const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `<article class="flow-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-port out"></i></article>`;
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28; const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:modeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:plan.control_strategy || 'setpoint'}),badge:tr('simulation.house')})); nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:plan.control_strategy || 'setpoint'}),badge:tr('simulation.house')}));
nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''})); nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''}));
zones.forEach((planZone, index) => { zones.forEach((planZone, index) => {
@@ -458,10 +470,10 @@ function renderSimulationPage() {
nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(planZone.device_name || '')}</span></div>`); nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(planZone.device_name || '')}</span></div>`);
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'})); nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:modeLabel(sim.mode)})); nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)}));
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge})); nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : ''; const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.demand?'RUN':'IDLE',badgeClass:sim.demand?'active':''})); nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?'RUN':'IDLE'),badgeClass:sim.demand?'active':''}));
nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:''})); nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:''}));
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link')); links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
@@ -514,9 +526,10 @@ function deviceFeaturePanel(device) {
function deviceCard(device, detailed = false) { function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat']; const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5]; const fans = [0,1,3,5];
const error = device.last_error const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms));
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>` const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
: `<small>${esc(device.ip)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}</small>`; const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto');
const networkInfo = `<div class="device-network-info"><small>${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}</small>${device.last_error ? `<small class="device-network-error" title="${esc(device.last_error)}">${esc(device.last_error)}</small>` : ''}</div>`;
return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}"> return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
<div class="device-head"> <div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div> <div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
@@ -536,7 +549,7 @@ function deviceCard(device, detailed = false) {
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`} ${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
</div> </div>
${detailed ? deviceFeaturePanel(device) : ''} ${detailed ? deviceFeaturePanel(device) : ''}
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''} ${detailed ? `<div class="card-footer">${networkInfo}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`; </article>`;
} }
@@ -1410,8 +1423,23 @@ document.addEventListener('click', async event => {
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); } try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); }
catch(error){ toast(error.message,true); } return; catch(error){ toast(error.message,true); } return;
} }
if (action === 'house-power') {
const power = button.dataset.value === 'true';
try {
button.disabled = true;
const result = await api('/api/house/power',{method:'POST',body:{power}});
app.settings = result.settings || app.settings;
app.devices = result.devices || app.devices;
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (failed) toast(tr('house.powerPartial', {count: failed}), true);
else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone'));
} catch(error) { toast(error.message, true); }
finally { button.disabled = false; }
return;
}
if (action === 'house-preset') { if (action === 'house-preset') {
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); scheduleControlPlanLoad(); toast(tr('house.presetUpdated')); } try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderHouseClimate(); renderZones(); scheduleControlPlanLoad(); toast(tr('house.presetUpdated')); }
catch(error){ toast(error.message,true); } return; catch(error){ toast(error.message,true); } 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-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; }
@@ -1580,6 +1608,7 @@ function currentSettingsBody() {
discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000), discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000),
discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000', discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000',
house_mode: settings.house_mode || 'cool', house_mode: settings.house_mode || 'cool',
house_power_enabled: settings.house_power_enabled !== false,
control_strategy: 'setpoint', control_strategy: 'setpoint',
outdoor_assist_enabled: !!settings.outdoor_assist_enabled, outdoor_assist_enabled: !!settings.outdoor_assist_enabled,
history_retention_days: Number(settings.history_retention_days || 30), history_retention_days: Number(settings.history_retention_days || 30),
+4
View File
@@ -38,6 +38,10 @@
</div> </div>
<div class="metrics" id="metrics"></div> <div class="metrics" id="metrics"></div>
<div class="panel house-climate" id="houseClimate"></div> <div class="panel house-climate" id="houseClimate"></div>
<div class="panel house-power-panel">
<div><span class="eyebrow" data-i18n="house.powerAllEyebrow">Global power</span><h3 data-i18n="house.powerAllTitle">All air conditioners</h3><p data-i18n="house.powerAllHint">Direct power control for all enabled units, independent from thermostat presets.</p></div>
<div class="house-power-actions"><button id="housePowerOn" type="button" class="secondary" data-action="house-power" data-value="true" data-i18n="house.powerOnAll">Turn all on</button><button id="housePowerOff" type="button" class="secondary" data-action="house-power" data-value="false" data-i18n="house.powerOffAll">Turn all off</button></div>
</div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span><h2 data-i18n="plan.title">What happens next</h2></div></div> <div class="section-heading"><div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span><h2 data-i18n="plan.title">What happens next</h2></div></div>
<div class="automation-plan-grid" id="controlPlan"></div> <div class="automation-plan-grid" id="controlPlan"></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Quick thermostats</h2></div></div> <div class="section-heading"><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Quick thermostats</h2></div></div>
+13 -6
View File
@@ -144,12 +144,14 @@ h3 { margin-bottom: 10px; }
.mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; } .mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; }
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar { display: none; } .mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar { display: none; }
.mode-row button, .fan-row button { flex: 1 0 auto; min-width: 58px; padding: 9px 10px; color: var(--muted); background: var(--surface-muted); font-size: 12px; } .mode-row button, .fan-row button { flex: 1 0 auto; min-width: 58px; padding: 9px 10px; color: var(--muted); background: var(--surface-muted); font-size: 12px; }
.mode-row button.active, .fan-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; } .mode-row button.active, .fan-row button.active, .house-mode-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; } .device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
.device-toggles button { padding: 9px 5px; color: var(--muted); font-size: 11px; } .device-toggles button { padding: 9px 5px; color: var(--muted); font-size: 11px; }
.device-toggles button.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } .device-toggles button.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); } .card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.card-footer small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } .card-footer small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.device-network-info { display: grid; min-width: 0; gap: 2px; }
.device-network-error { color: var(--danger) !important; }
.card-menu { display: flex; gap: 5px; } .card-menu { display: flex; gap: 5px; }
.card-menu button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; } .card-menu button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; }
.list-card { padding: 19px; } .list-card { padding: 19px; }
@@ -296,7 +298,7 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
.mode-row, .fan-row, .preset-row { scrollbar-width: none; } .mode-row, .fan-row, .preset-row { scrollbar-width: none; }
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar, .preset-row::-webkit-scrollbar { display: none; } .mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar, .preset-row::-webkit-scrollbar { display: none; }
.house-climate { display: grid; gap: 16px; margin: -15px 0 34px; } .house-climate { display: grid; gap: 16px; margin: -15px 0 12px; }
.house-climate-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .house-climate-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.house-climate-head h3 { margin: 2px 0 4px; font-size: 20px; } .house-climate-head h3 { margin: 2px 0 4px; font-size: 20px; }
.house-climate-head p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; } .house-climate-head p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
@@ -322,10 +324,13 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
.plan-house { border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); } .plan-house { border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
.plan-loading { grid-column: 1/-1; padding: 18px; color: var(--muted); } .plan-loading { grid-column: 1/-1; padding: 18px; color: var(--muted); }
.house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.house-mode-row button { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border: 1px solid transparent; color: var(--muted); background: var(--surface-muted); } .house-mode-row button { min-height: 44px; color: var(--muted); background: var(--surface-muted); }
.house-mode-row .mode-indicator { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--muted-2); transition: background .15s ease, box-shadow .15s ease, transform .15s ease; } .house-power-panel { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 34px; }
.house-mode-row button.active { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--surface)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent), 0 0 18px color-mix(in srgb, var(--accent) 28%, transparent), inset 0 0 14px color-mix(in srgb, var(--accent) 8%, transparent); font-weight: 800; } .house-power-panel h3 { margin: 2px 0 4px; font-size: 18px; }
.house-mode-row button.active .mode-indicator { background: var(--accent); box-shadow: 0 0 4px var(--accent), 0 0 12px color-mix(in srgb, var(--accent) 82%, transparent); transform: scale(1.12); } .house-power-panel p { margin: 0; max-width: 650px; color: var(--muted); font-size: 12px; line-height: 1.45; }
.house-power-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.house-power-actions button { min-height: 40px; }
.house-power-actions button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
.zone-thermostat { overflow: hidden; } .zone-thermostat { overflow: hidden; }
.zone-thermostat.demanding { border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); } .zone-thermostat.demanding { border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); }
@@ -358,6 +363,8 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
.house-climate { margin-top: -20px; } .house-climate { margin-top: -20px; }
.house-climate-head { align-items: flex-start; } .house-climate-head { align-items: flex-start; }
.house-climate-head p { max-width: 220px; } .house-climate-head p { max-width: 220px; }
.house-power-panel { align-items: stretch; flex-direction: column; }
.house-power-actions { display: grid; grid-template-columns: 1fr 1fr; }
.thermostat-main { grid-template-columns: 1fr auto 1fr; gap: 4px; } .thermostat-main { grid-template-columns: 1fr auto 1fr; gap: 4px; }
.thermostat-main .temperature-control.compact { gap: 9px; } .thermostat-main .temperature-control.compact { gap: 9px; }
.thermostat-main .target-temp.compact { min-width: 75px; font-size: 28px; } .thermostat-main .target-temp.compact { min-width: 75px; font-size: 28px; }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v064'; const CACHE = 'gree-controller-v065';
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')];