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
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
@@ -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).
- 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]]
name = "gree-controller"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.6.4"
version = "0.6.5"
edition = "2021"
authors = ["GREE Controller contributors"]
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
58037959419a8de25bcf9095b020bc3ab4c1559b975764f8d24b6359ad063ad6 ./BUILD_REPORT.md
436fd46220ef759d8204f24bdd94157c965cc39976b0f9baa79cebb367ee5c85 ./Cargo.lock
53f3f794080df48e11f5726678f7817c225ab64001dd1081d48c5aa780b2a7d0 ./Cargo.toml
c6ea33a11a287f159c6e4c5f19967d8599146692cd576cfe6b8ccacddadd558a ./BUILD_REPORT.md
c9673a32218f054f3d3d3b7f89cba3f0aa75f7078558e43ed3adff064e65d209 ./Cargo.lock
735f566fe24949e8a4fae1bebd42d12e91a91cb99a36b114803f0cf0b93212a6 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
be12639245e0929b12d11946b58c1d74f261733724bc4096a9ac68b12df30f24 ./README.md
90fe7b3c18f2ce0d244b8134b73841d3b416d33f7c13d3da107517a124c58d7e ./README.md
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
674f8cf1ab9f1a49188e20b8e3977f1e2b7e9b8ef975315266730c44a55bbb48 ./docs/API.md
6e0e5eb103c4acdaeb04c224fdd49e7ec051c667732d131439a165ac1739b713 ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md
19e6532f21c59b93c77863e8a8ac3a7d9abaf869f18e30401b7d0975b404b06e ./docs/PROJECT_SPEC.md
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
38ac4aec7b971ae7df8db77faf5f9cb9702710ddf9a97def448fbe9e9ff138b1 ./home-assistant/README.md
248570ceff0e020c9d81e8b9e1622ce33f3350f51a04371a9f4fc3f8fa13f62c ./home-assistant/README.md
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
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
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/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
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py
7c23374d1c82e4b361089386ff89265322b80def0f4db9d3fd2c039867033105 ./home-assistant/custom_components/gree_controller/switch.py
459233ccefa7827fc1c6887146d8a69e01149a497635fd5e1802c220f418eb28 ./home-assistant/custom_components/gree_controller/select.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
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
883fc8f52e9d79d41a7c75ca18edccd344ac510e0a36027b76467908ede1c198 ./lang/en.json
4d711b95cb86ad8710ed74bc7f4b49888cb82ac592e18d83b9b7e17def0bc029 ./lang/pl.json
997c7a91017b5d51b3e44016d21252b37d302f95ba0fb5802f3d8fd116687d6f ./lang/en.json
33f6935ad1fe72ea0e6893e230ff95565388410fb0fbfe974b34610d789c5d2b ./lang/pl.json
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
@@ -45,26 +46,26 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
87b985ba7095a3b165928fe6d051acf34c34d2b48ce882b2b2c89c486f3f2ad3 ./src/api.rs
0f50b30a0088b6ae8289ecf42be1938cbcd95f672d370cff7e5446e7e1f02b83 ./src/config.rs
f8d1349236819fe0f0253df5269353245f0cacf5b29614ac79c2317c16943636 ./src/api.rs
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs
4a74955d2f5f830bae4758ed078157b3fb1a54c67f338e48ea1f2765efbd980f ./src/engine.rs
8ec14c01036c73f28cdb330d696f0d3573198e7ea4d03ba6bf9cc1d34ec6c7a8 ./src/engine.rs
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs
6be14e8e3286c9ce5bd921ccaa39431bb2d3cd882d2945fa8b17b2f00bf5a8fb ./src/models.rs
69cd06183d7492112ed74096cf629b8f4dc073a4b7f15f94b223b299b6244582 ./src/models.rs
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
37bd46ac7c50d1674463eddcf2ee9c85cdbf8fe0829cef13a3f5fe857d3d0f55 ./src/protocol/gree.rs
0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs
f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
c1bcdbbdf45d11aa87aebd82b8553316715e289845563f5af0e493e61243f19c ./web/app.js
53b94be090889a8f709b16b0f88e9e9c732427151de3cb740c1f16144dec37b4 ./web/app.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
002c172392a00006a5128fc4cb881b0361a2d3e76298dbd80c0a013f4e1f9dde ./web/index.html
59e85721942f533243d12491e201dbfedef859dc752b7f0c7c37405705832d2d ./web/index.html
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
80ea1c50c3729adb4dd392b12d73b4247659a8b6b948ee76bd2f5df213eae918 ./web/styles.css
03fdeecaa4c68308668c09077e5390286c27d2a8e296367bec6b002fb9b8b3e8 ./web/sw.js
a9643913bf5ceba3b0940fff583350f5bb853e7d0421296a9661a4e3e256bde1 ./web/styles.css
e804540ecc928c3f29a8e71fdae68a6e0a36eb0c0698b1ca22cf8f42a888d61c ./web/sw.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.
Current version: **0.6.4**.
Current version: **0.6.5**.
## 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:
```bash
unzip gree-controller-v0.6.4.zip
unzip gree-controller-v0.6.5.zip
cd gree-controller
chmod +x scripts/*.sh
./scripts/dev.sh
@@ -221,7 +221,7 @@ Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep now
## 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.
@@ -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.
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.
@@ -294,6 +294,7 @@ POST /api/zones
POST /api/zones/:id/control
POST /api/zones/:id/schedule-template
POST /api/house/control
POST /api/house/power
POST /api/house/preset
GET /api/schedules
POST /api/schedules
@@ -316,6 +317,9 @@ POST /api/integrations/home-assistant/test
GET /api/integrations/home-assistant/devices
POST /api/integrations/home-assistant/devices/{id}/command
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
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
POST /api/integrations/home-assistant/devices/{id}/command
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
```
@@ -63,17 +66,20 @@ Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. Option
## 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
curl -X POST "$BASE/api/house/control" -H "$AUTH" -H 'Content-Type: application/json' \
-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' \
-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:
@@ -167,13 +173,13 @@ This setting is opt-in and applies only to the controller's outbound Home Assist
## 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
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
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
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**:
+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.
## 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")
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]:
"""Change a zone thermostat override through the controller."""
data = await self._request(
@@ -3,7 +3,7 @@
from homeassistant.const import Platform
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_TOKEN = "token"
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.6.4",
"version": "0.6.5",
"config_flow": true,
"integration_type": "hub",
"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"),
"outdoor_temperature": plan.get("outdoor_temperature"),
"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),
"demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones),
"next_events": plan.get("next_events", []),
@@ -35,9 +35,12 @@ async def async_setup_entry(
"""Create one enabled switch per controller zone."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SwitchEntity] = [
GreeControllerHousePowerSwitch(runtime.coordinator),
*[
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
for zone in runtime.coordinator.plan.get("zones", [])
if zone.get("zone_id")
],
]
for device_id, device in runtime.coordinator.data.items():
for field, support_field, name in DEVICE_FEATURE_SWITCHES:
@@ -46,6 +49,35 @@ async def async_setup_entry(
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):
"""Enable or disable a controller thermostat zone."""
+13 -3
View File
@@ -244,7 +244,7 @@
"mode.off": "Off",
"house.seasonMode": "Home climate mode",
"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.modeUpdated": "Home climate mode updated",
"zones.modePolicy": "Mode policy",
@@ -437,7 +437,7 @@
"simulation.stateDemand": "Demand",
"simulation.stateSatisfied": "Satisfied",
"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.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.",
@@ -530,6 +530,16 @@
"logs.integrations": "Integrations",
"logs.systemOther": "System/other",
"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",
"house.seasonMode": "Tryb klimatu domu",
"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.modeUpdated": "Zmieniono tryb klimatu domu",
"zones.modePolicy": "Polityka trybu",
@@ -437,7 +437,7 @@
"simulation.stateDemand": "Zapotrzebowanie",
"simulation.stateSatisfied": "Osiągnięto cel",
"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.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ść.",
@@ -530,6 +530,16 @@
"logs.integrations": "Integracje",
"logs.systemOther": "System/inne",
"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/schedule-template", post(apply_schedule_template))
.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/schedules", get(list_schedules).post(create_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/:id/command", post(command_device))
.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_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,
temperature_sensor_offset: None,
online: input.simulated,
response_time_ms: if input.simulated { Some(0) } else { None },
last_seen: if input.simulated { Some(now) } else { None },
last_error: None,
communication_failures: 0,
@@ -602,6 +607,56 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
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)]
struct HousePresetPatch { preset: String }
@@ -1379,6 +1434,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"discovery_timeout_ms": settings.discovery_timeout_ms,
"discovery_broadcast": settings.discovery_broadcast,
"house_mode": settings.house_mode,
"house_power_enabled": settings.house_power_enabled,
"control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
"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_broadcast: self.discovery_broadcast.clone(),
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
house_power_enabled: true,
control_strategy: "setpoint".into(),
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),
+53 -21
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
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 };
if command.is_empty() { return Ok(device); }
let suppress_beep = state.settings.read().await.suppress_device_beep;
let response_started = Instant::now();
let mut applied_command = command.clone();
if device.simulated {
applied_command.apply(&mut device);
device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now());
device.last_error = None;
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);
device.online = true;
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_error = None;
state.db.save_device(&device)?;
@@ -199,6 +202,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
return;
}
let previous_failures = device.communication_failures;
let response_started = Instant::now();
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
Ok(bound) => {
@@ -227,6 +231,9 @@ async fn poll_device(state: &AppState, device: &mut Device) {
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;
}
@@ -268,6 +275,7 @@ fn simulate_tick(device: &mut Device) {
// Correct rounding for outdoor temperature without accumulating precision noise.
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now());
device.last_error = None;
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 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()? {
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
@@ -381,9 +399,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
};
let effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
// House "off" means the smart thermostat does not control inherited zones.
// A zone explicitly switched to heat/cool remains independent and may still run.
let effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
@@ -444,18 +462,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
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" {
zone.active_preset = "off".into();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
if device.power {
match send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
Ok(_) => zone.last_action_at = Some(Utc::now()),
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
zone.demand_since = None;
zone.target_alerted_at = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
state.db.save_zone(&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.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
@@ -915,28 +931,41 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let settings = state.settings.read().await.clone();
let schedules = state.db.list_schedules()?;
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 night_active = night_mode_active(&settings.night_mode, now.time());
let mut zones_out = Vec::new();
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 effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
let effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
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" {
("off".to_string(), None)
("manual".to_string(), None)
} else {
let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
(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) {
let mut event = event.clone();
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(),
configured_mode: zone.mode.clone(),
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,
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)),
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(),
manual_override_until: zone.manual_override_until,
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 {
generated_at: Utc::now(),
house_mode: settings.house_mode,
house_preset,
house_power,
outdoor_temperature: *state.outdoor_temperature.read().await,
control_strategy: settings.control_strategy,
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<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?;
for mut item in state.db.list_automations()? {
if !item.enabled || !automation_ready(&item) { continue; }
+12 -1
View File
@@ -108,6 +108,9 @@ pub struct Device {
pub temperature_sensor_offset: Option<bool>,
#[serde(default)]
pub online: bool,
/// Round-trip time of the latest successful controller communication.
#[serde(default)]
pub response_time_ms: Option<u64>,
#[serde(default)]
pub last_seen: Option<DateTime<Utc>>,
#[serde(default)]
@@ -158,6 +161,7 @@ impl Device {
outdoor_temperature: Some(30.0),
temperature_sensor_offset: Some(false),
online: true,
response_time_ms: Some(0),
last_seen: Some(now),
last_error: None,
communication_failures: 0,
@@ -667,6 +671,10 @@ pub struct AutomationPlanRule {
pub struct ControlPlan {
pub generated_at: DateTime<Utc>,
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 control_strategy: String,
pub night_mode_active: bool,
@@ -686,9 +694,12 @@ pub struct RuntimeSettings {
pub zone_interval_seconds: u64,
pub discovery_timeout_ms: u64,
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")]
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.
#[serde(default = "default_control_strategy")]
pub control_strategy: String,
+1
View File
@@ -269,6 +269,7 @@ impl GreeClient {
outdoor_temperature: None,
temperature_sensor_offset: None,
online: true,
response_time_ms: None,
last_seen: Some(now),
last_error: None,
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 historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value);
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 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;
@@ -246,14 +247,20 @@ function renderSummary() {
function renderHouseClimate() {
const node = $('#houseClimate'); if (!node || !app.settings) return;
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 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>
<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==='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==='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==='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'}">${esc(tr('mode.heat'))}</button>
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off" aria-pressed="${mode==='off'}">${esc(tr('house.noControl'))}</button>
</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) {
@@ -276,11 +283,11 @@ function renderControlPlan() {
return;
}
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 events = (zone.next_events || []).slice(0, 3);
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('');
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>` : '';
@@ -330,7 +337,7 @@ function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
function simulationStatusInfo(status) {
const map = {
disabled: {label: tr('common.disabled'), badge: ''},
off: {label: tr('mode.off'), badge: ''},
off: {label: tr('house.noControl'), badge: ''},
waiting: {label: tr('simulation.waitingForData'), badge: ''},
demand: {label: tr('simulation.stateDemand'), badge: 'active'},
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 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 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 hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1);
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 nightActive = !!app.controlPlan?.night_mode_active;
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);
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;
let fanSpeed = simulationNumeric(device?.fan_speed);
if (mode !== 'off') {
fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed;
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
? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const quiet = mode === 'off'
? tr('simulation.manual')
: nightActive && app.settings?.night_mode?.force_quiet
? 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
? tr('simulation.reasonDisabled')
: mode === 'off'
@@ -416,7 +428,7 @@ function renderSimulationPage() {
const demandingZones = enabledZones.filter(zone => zone.demand);
const nightOn = !!plan.night_mode_active;
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('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}`},
@@ -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 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':''}));
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(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}));
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:''}));
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
@@ -514,9 +526,10 @@ function deviceFeaturePanel(device) {
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
const error = device.last_error
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>`
: `<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 hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms));
const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
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)}">
<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>
@@ -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>`}
</div>
${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>`;
}
@@ -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')); }
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') {
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;
}
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_broadcast: settings.discovery_broadcast || '255.255.255.255:7000',
house_mode: settings.house_mode || 'cool',
house_power_enabled: settings.house_power_enabled !== false,
control_strategy: 'setpoint',
outdoor_assist_enabled: !!settings.outdoor_assist_enabled,
history_retention_days: Number(settings.history_retention_days || 30),
+4
View File
@@ -38,6 +38,10 @@
</div>
<div class="metrics" id="metrics"></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="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>
+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::-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.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 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)); }
.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; }
.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 button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; }
.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::-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 h3 { margin: 2px 0 4px; font-size: 20px; }
.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-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 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 .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-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-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-mode-row button { min-height: 44px; color: var(--muted); background: var(--surface-muted); }
.house-power-panel { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 34px; }
.house-power-panel h3 { margin: 2px 0 4px; font-size: 18px; }
.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.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-head { align-items: flex-start; }
.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 .temperature-control.compact { gap: 9px; }
.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 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')];