v0.6.5
This commit is contained in:
+10
-2
@@ -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
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,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')];
|
||||
|
||||
Reference in New Issue
Block a user