From fd26d6877f36317b82ecafed774eb940a465e28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 25 Aug 2026 09:31:15 +0200 Subject: [PATCH] v0.6.5 --- BUILD_REPORT.md | 12 +- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 49 ++++---- README.md | 12 +- docs/API.md | 14 ++- docs/PROJECT_SPEC.md | 2 +- home-assistant/README.md | 9 ++ .../custom_components/gree_controller/api.py | 33 ++++++ .../gree_controller/const.py | 2 +- .../gree_controller/manifest.json | 2 +- .../gree_controller/select.py | 105 ++++++++++++++++++ .../gree_controller/sensor.py | 2 + .../gree_controller/switch.py | 32 ++++++ lang/en.json | 16 ++- lang/pl.json | 16 ++- src/api.rs | 56 ++++++++++ src/config.rs | 1 + src/engine.rs | 74 ++++++++---- src/models.rs | 13 ++- src/protocol/gree.rs | 1 + web/app.js | 79 ++++++++----- web/index.html | 4 + web/styles.css | 19 +++- web/sw.js | 2 +- 25 files changed, 460 insertions(+), 99 deletions(-) create mode 100644 home-assistant/custom_components/gree_controller/select.py diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 1d03f45..11933d5 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -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._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`. diff --git a/Cargo.lock b/Cargo.lock index 90c82a5..9df77cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.6.4" +version = "0.6.5" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 8aed271..b254941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index c81aa04..6cc0d59 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index dcbed02..ac90c4d 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/API.md b/docs/API.md index 414eb29..370656b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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" \ diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 5cbcd33..9061356 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -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**: diff --git a/home-assistant/README.md b/home-assistant/README.md index cd480e7..d488031 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -126,3 +126,12 @@ From version 0.6.4, zone climate entities use `climate._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. diff --git a/home-assistant/custom_components/gree_controller/api.py b/home-assistant/custom_components/gree_controller/api.py index 79bcf7b..592c423 100644 --- a/home-assistant/custom_components/gree_controller/api.py +++ b/home-assistant/custom_components/gree_controller/api.py @@ -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( diff --git a/home-assistant/custom_components/gree_controller/const.py b/home-assistant/custom_components/gree_controller/const.py index 80a513f..f8af1ae 100644 --- a/home-assistant/custom_components/gree_controller/const.py +++ b/home-assistant/custom_components/gree_controller/const.py @@ -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" diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index d71fd76..9788eb5 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -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", diff --git a/home-assistant/custom_components/gree_controller/select.py b/home-assistant/custom_components/gree_controller/select.py new file mode 100644 index 0000000..afe807d --- /dev/null +++ b/home-assistant/custom_components/gree_controller/select.py @@ -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() diff --git a/home-assistant/custom_components/gree_controller/sensor.py b/home-assistant/custom_components/gree_controller/sensor.py index 9857a38..998e5fc 100644 --- a/home-assistant/custom_components/gree_controller/sensor.py +++ b/home-assistant/custom_components/gree_controller/sensor.py @@ -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", []), diff --git a/home-assistant/custom_components/gree_controller/switch.py b/home-assistant/custom_components/gree_controller/switch.py index e2bdb10..bd84311 100644 --- a/home-assistant/custom_components/gree_controller/switch.py +++ b/home-assistant/custom_components/gree_controller/switch.py @@ -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.""" diff --git a/lang/en.json b/lang/en.json index e5f43f4..990afde 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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" } } diff --git a/lang/pl.json b/lang/pl.json index bf46f04..845dd69 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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" } } diff --git a/src/api.rs b/src/api.rs index 5cc59d8..550c045 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, Json(input): Json, Json(input): Json, Json(input): Json) -> Result, 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, diff --git a/src/config.rs b/src/config.rs index ab208fc..9e1da37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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), diff --git a/src/engine.rs b/src/engine.rs index 96c3396..13ae142 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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 Result Result 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; } diff --git a/src/models.rs b/src/models.rs index 4f6e450..511d809 100644 --- a/src/models.rs +++ b/src/models.rs @@ -108,6 +108,9 @@ pub struct Device { pub temperature_sensor_offset: Option, #[serde(default)] pub online: bool, + /// Round-trip time of the latest successful controller communication. + #[serde(default)] + pub response_time_ms: Option, #[serde(default)] pub last_seen: Option>, #[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, pub house_mode: String, + /// Uniform whole-house preset when every zone uses the same override; None for a mixed state. + pub house_preset: Option, + /// Whole-house master power state. + pub house_power: bool, pub outdoor_temperature: Option, 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, diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index 809d7e8..356a458 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -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, diff --git a/web/app.js b/web/app.js index 4e4554b..a1ffece 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = `
${esc(tr('house.seasonMode'))}

${esc(tr('house.smartThermostat'))}

${esc(tr('house.setpointStrategy'))}

${esc(tr('house.outdoor'))}${esc(outdoor)}
- - - + + +
-
${['auto','comfort','sleep','away'].map(p=>``).join('')}
`; +
${['auto','comfort','sleep','away'].map(p=>``).join('')}
`; } function planEventMarkup(event) { @@ -276,11 +283,11 @@ function renderControlPlan() { return; } const houseEvents = (plan.next_events || []).slice(0, 5); - const house = `
${esc(tr('plan.house'))}

${esc(modeLabel(plan.house_mode || 'off'))}

${esc(plan.control_strategy || 'setpoint')}

${esc(tr('plan.houseSummary', {zones:(plan.zones || []).filter(zone=>zone.enabled).length, demand:(plan.zones || []).filter(zone=>zone.enabled && zone.demand).length}))}

    ${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; + const house = `
${esc(tr('plan.house'))}

${esc(houseModeLabel(plan.house_mode || 'off'))}

${esc(plan.control_strategy || 'setpoint')}

${esc(tr('plan.houseSummary', {zones:(plan.zones || []).filter(zone=>zone.enabled).length, demand:(plan.zones || []).filter(zone=>zone.enabled && zone.demand).length}))}

    ${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; 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 `
${esc(zone.device_name || tr('common.noDevice'))}

${esc(zone.zone_name)}

${esc(zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'))}
${fmtTemp(zone.current_temperature)}${esc(target)}°C

${esc(modeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; + return `
${esc(zone.device_name || tr('common.noDevice'))}

${esc(zone.zone_name)}

${esc(zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'))}
${fmtTemp(zone.current_temperature)}${esc(target)}°C

${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; }).join(''); const rules = (plan.rules || []).filter(rule => rule.enabled); const ruleCard = rules.length ? `
${esc(tr('plan.rules'))}

${esc(tr('plan.ruleCount', {count:rules.length}))}

    ${rules.slice(0,5).map(rule=>`
  • ${esc(rule.name)} → ${esc(rule.action_device_name || '')}
  • `).join('')}
` : ''; @@ -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=''}) => `
${esc(eyebrow)}${badge ? `${esc(badge)}` : ''}

${esc(title)}

${esc(value)}${meta ? `

${esc(meta)}

` : ''}
`; 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(`
${esc(planZone.zone_name)}${esc(planZone.device_name || '')}
`); 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 - ? `${esc(device.last_error)}` - : `${esc(device.ip)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}`; + 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 = `
${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}${device.last_error ? `${esc(device.last_error)}` : ''}
`; return `

${esc(device.name)}

${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

@@ -536,7 +549,7 @@ function deviceCard(device, detailed = false) { ${device.supports_turbo === false ? '' : ``}
${detailed ? deviceFeaturePanel(device) : ''} - ${detailed ? `` : ''} + ${detailed ? `` : ''}
`; } @@ -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), diff --git a/web/index.html b/web/index.html index c55e9a8..079f22a 100644 --- a/web/index.html +++ b/web/index.html @@ -38,6 +38,10 @@
+
+
Global power

All air conditioners

Direct power control for all enabled units, independent from thermostat presets.

+
+
Control plan

What happens next

Zones

Quick thermostats

diff --git a/web/styles.css b/web/styles.css index 126f27b..4b79a71 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; } diff --git a/web/sw.js b/web/sw.js index bfb1ff8..4474670 100644 --- a/web/sw.js +++ b/web/sw.js @@ -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')];