This commit is contained in:
Mateusz Gruszczyński
2026-08-25 22:14:14 +02:00
parent 0d65a6a946
commit 9fa0a5399e
16 changed files with 540 additions and 84 deletions
+44 -1
View File
@@ -1,4 +1,4 @@
# GREE Controller v0.7.3 - build and validation report
# GREE Controller v0.7.4 - build and validation report
## Scope
@@ -282,3 +282,46 @@ Validation for the 0.7.1 package:
- `bash -n scripts/*.sh` passed.
- HTML parsing and CSS brace-balance checks passed.
- The validation environment does not provide `cargo`/`rustc`, so the Rust test/build step must run on the target host/LXC (the normal `scripts/update.sh` performs `cargo test --all-targets` before installation).
## 2026-08-25 disabled-zone and standby-command hotfix
- Disabled thermostat zones are excluded from normal house, group-loop and direct-device automation ownership while sensor polling/history continue normally.
- Disabling an active zone now sends one immediate power-off command to its assigned unit. Later manual operation remains possible through the technical Devices control path.
- Whole-house ON only powers devices assigned to enabled thermostat zones; unassigned devices and devices belonging to disabled zones are not started.
- Whole-house OFF now cascades group power gates to OFF and powers off all enabled physical devices. Whole-house ON restores group gates but still respects disabled zones.
- Restricted Home Assistant direct-device commands are rejected for devices assigned to disabled zones; zone/group/house endpoints keep their managed semantics.
- Dashboard direct-device controls are locked for devices assigned to disabled zones and point the operator to the Devices view for explicit technical/manual control.
- Smart Quiet now follows demand transitions instead of being reasserted throughout steady standby. Outside the night window it no longer fights Smart Fan by toggling Quiet off/on on successive cycles.
- Standby Low fan is sent on a relevant transition/core thermostat change rather than retried forever when firmware reports a different fan state, preventing periodic command/beep loops with unchanged room temperature and setpoint.
- Added regression assertions for steady-standby Smart Quiet behavior and night-mode/Smart-Fan interaction.
- PWA cache key changed so browsers fetch the corrected frontend and language strings after deployment.
Validation in this packaging environment:
- `node --check web/app.js` and `node --check web/sw.js` passed.
- PL/EN application language JSON parsed successfully and translation-key parity passed.
- `web/index.html` parsed successfully with Python's HTML parser.
- Home Assistant Python modules and JSON manifests/translations passed syntax/parse checks.
- Shell scripts passed `bash -n`.
- Rust source was statically reviewed and delimiter-checked. This environment does not provide `cargo`, `rustc` or `rustfmt`; the normal target update path must run `cargo test --all-targets` and `cargo build --release` before installation.
## 0.7.4 physical/manual takeover
- Added persistent per-zone device manual override state with source fields, start time and optional next-schedule expiry.
- Polling detects external power/mode/setpoint/fan changes relative to the last controller-known state and suspends automatic correction for that zone.
- Known standby Low-to-Auto fan normalization is ignored to avoid false remote-control detection.
- Technical climate commands from the Devices endpoint enter the same takeover; light-only/non-thermostat feature changes do not.
- Manual takeover has priority over zone modulation, climate groups and direct-device automations. Whole-house power-off is the explicit exception and clears all takeovers.
- Added Web UI Manual control / remote state and Resume automation action, plus control-plan fields for integrations/simulation.
- Added regression tests for external climate-state detection, Low-to-Auto suppression and takeover reset state.
### 0.7.4 validation
- `node --check` passed for `web/app.js` and `web/sw.js`.
- PL/EN language packs parse successfully, have identical key sets (586 keys each), and all UI translation references resolve.
- Home Assistant Python modules and helper Python scripts pass `py_compile`; shell scripts pass `bash -n`.
- Cargo metadata parses from `Cargo.toml`; package, Cargo lock root package and Home Assistant manifest report `0.7.4`.
- `web/index.html` parses successfully and CSS braces are balanced.
- Rust source delimiter/static invariant checks pass, including poll detection, group/automation guards, whole-house override clear and technical manual-command routing.
- The packaging environment has no `cargo`, `rustc` or `rustfmt`; the target update path remains the compile gate and must run `cargo test --all-targets` and `cargo build --release` before replacing the running binary.
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.7.3"
version = "0.7.4"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.7.3"
version = "0.7.4"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+15 -15
View File
@@ -1,12 +1,12 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
f6c797abd321abcf1ad9ab454e2a2949d588ed1465b12fd3383530c71bda9070 ./BUILD_REPORT.md
cff434b43ec947285275a72653bde756b57987d7191ec4093f9218bd16653936 ./Cargo.lock
f3e0db0deb28cda7669b9344436d883529143f64e8dc89624986fe14b9fa502d ./Cargo.toml
49062c81883c8208913641da1d3ba15a006e1a9fdcbac126f75bbc9622d7deca ./BUILD_REPORT.md
179774a6d5ebb4d9ea5b9713f66658b5488d142132b4cffd44fa39bf79072bbc ./Cargo.lock
9d24427676b6d8dec334ed5ebc3bbc2318d2203f1edebbeecaf83bee4e882203 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
b526a6b28e1b8cb68be8bd92627a8588c0964de109f99b83c173ecec151ae543 ./README.md
f30bfd3fc5ca95db2902b984ad82a95a73595e2b91e94db1a645cee9d17526d1 ./README.md
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
b396c7016569a5817ed740abadd0e5685a8e7d50300bfce607cc3c15009ba78d ./docs/API.md
eb343972b711426ca1e81684ffdc5f869db003b13afb6fd3828b954f8df7d964 ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
@@ -23,7 +23,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista
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
61cb322659bf9cdc2490b20ad3c5aab92def81660d6a01dbcd978ddebc162813 ./home-assistant/custom_components/gree_controller/manifest.json
70fdf131ad73f4dd4cbecdb70a2014d6fae02d7cd8f31d0ea5367e4f7a887c7f ./home-assistant/custom_components/gree_controller/manifest.json
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./home-assistant/custom_components/gree_controller/select.py
047f3db03f0db54a40d63092370e5861510d611edfb0a0659e4c25b949d47854 ./home-assistant/custom_components/gree_controller/sensor.py
@@ -32,8 +32,8 @@ a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./home-assista
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
cad2eb081cf7fbed275507763aafe288f25ae527c6775c84b89c1ac095d6843b ./lang/en.json
694c88c40860f7b14bcc6bd0575b6a53062302a4f1c29b7cb5829c00028dfa03 ./lang/pl.json
e11f49c05499b7dafac87cab2fe40022f43dc88df7dec6831c2c4ca8e4235cc0 ./lang/en.json
af59c7fdd4a48a59305cba6518291238b16089c3876b837ca7b8db57d691c507 ./lang/pl.json
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
@@ -46,15 +46,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
7b4309a30eed56af51c175db00e12649921edc6f515df477c68bc09c750929c9 ./src/api.rs
c2d15048e637e5582fdf2b8efafec3b8e212592d117a38dcee3f4f9354742669 ./src/api.rs
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
0f818e27983476ad41bf8f647749bc5a7dc7924731bb436ec996b0c8e595f93d ./src/engine.rs
32711788456b9e432b6954069706b8c313fe886d1d1b648e235e780e2082fed6 ./src/engine.rs
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs
13fb67f279f97410f9632401a887547c7b5c7f43c40f3220253bfa3e86b600a4 ./src/models.rs
9e53273156b0bcc510726ff6bc5923d8c845873c8638f4fe2f27553607e3cd5b ./src/models.rs
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs
@@ -62,10 +62,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
27c39b66faab98b71ea515715c3744105f26bc274587aa4e6d595e16f3674f22 ./web/app.js
a8d6df8b217321c94441f1a3d4a14a71f4dbce874e8d3e9b7e1e2d323551ffb3 ./web/app.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
57949bed2229ceb180ea681b0126b56f6e75dc325d7a89d384f823282736bdc7 ./web/index.html
5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
e4ed0381250352f752e3b36133a89947db1adb08dec9cae7d2467684dbd1a807 ./web/styles.css
4a60c17e07f1c17d79d5316cb4a34418087f7428cc0326cf536c043261e926a4 ./web/sw.js
b3448404b5f7ff6970af5d9812213cdd8a458086e9405e0308001e2101afb44d ./web/styles.css
de6d0a8feda7f25a3fa45ccd32e41fd70a786f20f328640575c457d6e298ffd5 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+13 -3
View File
@@ -4,7 +4,7 @@
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
Current version: **0.7.3**.
Current version: **0.7.4**.
## Highlights
@@ -47,7 +47,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
On Debian, Ubuntu or an LXC container:
```bash
unzip gree-controller-v0.7.3.zip
unzip gree-controller-v0.7.4.zip
cd gree-controller
chmod +x scripts/*.sh
./scripts/dev.sh
@@ -222,7 +222,7 @@ Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep / Aw
## 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** 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.
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. Smart Quiet and standby Low fan are transition hints rather than commands that are blindly reasserted every adjustment interval, avoiding unnecessary beeps when the room and device setpoint are already stable. **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. Disabled zones are excluded from normal house/group/automation control; their physical unit remains available for explicit technical control from the Devices view.
Choose one seasonal **global mode** — Cooling in summer, Heating in winter, or Off. Zones inherit this global mode by default, while a zone may explicitly override Heat/Cool when required. In **Mode policy**, leave **Follow global mode** unchanged when the zone should inherit the mode selected globally in GREE Controller; **Cooling only** / **Heating only** are deliberate per-zone overrides. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating.
@@ -446,3 +446,13 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C
- Mixed member modes/profiles are reported without inventing a false common value; choosing a group option normalizes all member zones through the existing group-control engine.
- Group commands use the restricted Home Assistant access token and the dedicated `/api/integrations/home-assistant/groups/*` endpoints.
- After adding/removing/renaming groups, reload the GREE Controller integration in Home Assistant so its entity/device registry is rebuilt.
## 0.7.4 manual remote override
- Physical GREE changes detected during polling for power, HVAC mode, target temperature and meaningful fan-speed changes now enter a per-zone **Manual control / remote** takeover state.
- While takeover is active, thermostat modulation, schedules, groups and direct-device automations observe the zone but do not overwrite the unit.
- Takeover expires at the next schedule transition when available; otherwise it remains until **Resume automation** or another explicit quick-thermostat action.
- Technical climate control from the Devices view uses the same takeover semantics, while unrelated controls such as panel light do not pause the thermostat.
- Disabled zones remain outside normal group/automation ownership; their unit can be operated manually.
- **Turn all off** remains authoritative: it clears all takeovers, disables groups and powers off enabled units. Whole-house/group ON paths do not wake zones currently protected by manual takeover.
- The UI and control-plan API expose the takeover state and its optional expiry.
+17 -2
View File
@@ -81,7 +81,7 @@ curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/j
-d '{"preset":"sleep"}'
```
House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `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. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on and all enabled units are powered on. Choosing house mode `off` does not change master power.
House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `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. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on, all group power gates are enabled and only devices assigned to enabled thermostat zones that are not in manual-device override are powered on. `power:false` is authoritative: it clears manual-device overrides, disables every group and powers off every enabled physical device. Disabled zones otherwise remain outside house/group/automation control and their underlying unit can be operated manually through technical device control or its physical remote. Choosing house mode `off` does not change master power.
A zone stores separate profile temperatures for both seasons:
@@ -128,6 +128,19 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow global mode** in the UI and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones.
### Physical/manual device takeover
The poller compares climate-relevant unit state with the last controller-known state. An external change of power, HVAC mode, target temperature or fan speed (for example from the IR remote) sets `device_manual_override=true` on the assigned zone. The zone continues sensor/history updates but thermostat modulation, schedules, groups and direct-device automations stop issuing corrective climate commands. The override expires at the next schedule transition when one exists; without a future transition it stays active until explicitly resumed. The known GREE standby normalization from Low fan back to Auto is ignored so it does not create a false takeover.
The technical `POST /api/devices/{id}/command` path uses the same takeover semantics for climate-relevant changes; light-only and unrelated feature changes do not suspend the thermostat. An explicit quick-zone action (target, mode, preset, enabled state) resumes thermostat ownership automatically. It can also be resumed directly:
```bash
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"clear_device_manual_override":true}'
```
Whole-house `power:false` is the deliberate exception and always clears the takeover and powers the unit off. Group power/mode/profile commands do not fight a zone while its manual-device override is active.
### Climate groups
Groups persist a set of thermostat zone IDs and an independent group power gate. Group control reuses the normal zone engine, so profiles, schedules, hysteresis and sensor selection remain unchanged.
@@ -189,7 +202,7 @@ 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, 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.
`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 schedule override expiry, physical/manual device override state and expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events.
```bash
curl "$BASE/api/control-plan" -H "$AUTH"
@@ -197,6 +210,8 @@ curl "$BASE/api/control-plan" -H "$AUTH"
The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also read derived group state from `GET /api/integrations/home-assistant/groups`, control a group through `POST /api/integrations/home-assistant/groups/{id}/control`, change house mode/preset/master power through `/api/integrations/home-assistant/house/*`, and change a zone target/preset/mode/enabled state through:
Direct HA device commands are rejected while the device belongs to a disabled thermostat zone. Re-enable the zone for normal controller/HA ownership; the unrestricted `/api/devices/{id}/command` endpoint remains the technical/manual path used by the Devices view. Climate-relevant direct device commands on an enabled zone create the same manual-device override as a physical remote change, preventing the thermostat engine from immediately undoing them.
The group list reports the configured group power state, common mode/profile (or `mixed`), member zones, online device count, average room temperature, demand count and upcoming member schedule events. Group control accepts the same semantics as the Web UI:
```bash
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.7.3",
"version": "0.7.4",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
+8 -3
View File
@@ -73,6 +73,7 @@
"devices.readDone": "Device state updated",
"devices.bound": "Device bound",
"devices.added": "Device added",
"devices.disabledZoneTechnicalOnly": "This zone is disabled. Manual control of its unit is available only from the Devices view.",
"zones.automation": "Automation",
"zones.new": "New zone",
"zones.description": "Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature, preset and mode changes.",
@@ -538,10 +539,10 @@
"house.noControl": "Do not control",
"house.powerAllEyebrow": "Global power",
"house.powerAllTitle": "All air conditioners",
"house.powerAllHint": "Master power for all enabled units. “Turn all off” prevents them from restarting. Choosing whole-house Cooling/Heating or an Auto/Comfort/Sleep/Away profile automatically switches master power back on.",
"house.powerAllHint": "Master power for thermostat zones. “Turn all off” also turns off every group and unit. “Turn all on” starts enabled zones only; disabled zones remain disabled. Choosing whole-house Cooling/Heating or a profile automatically enables master power.",
"house.powerOnAll": "Turn all on",
"house.powerOffAll": "Turn all off",
"house.powerOnDone": "All air conditioners turned on",
"house.powerOnDone": "All enabled zones turned on",
"house.powerOffDone": "All air conditioners turned off",
"house.powerPartial": "Could not change power on {count} devices",
"preset.manual": "Manual",
@@ -586,6 +587,10 @@
"zones.controlNow": "Control",
"zones.noGroup": "no group",
"zones.coolOnly": "Cooling only",
"zones.heatOnly": "Heating only"
"zones.heatOnly": "Heating only",
"zones.manualDeviceControl": "Manual control / remote",
"zones.manualDeviceUntil": "Manual control / remote until {time}",
"zones.manualDeviceNoBoundary": "Manual control / remote · until resumed",
"zones.resumeAutomation": "Resume automation"
}
}
+8 -3
View File
@@ -73,6 +73,7 @@
"devices.readDone": "Odczyt zakończony",
"devices.bound": "Urządzenie powiązane",
"devices.added": "Urządzenie dodane",
"devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Ręczne sterowanie tą jednostką jest dostępne tylko w zakładce Urządzenia.",
"zones.automation": "Automatyka",
"zones.new": "Nowa strefa",
"zones.description": "Strefy definiują logikę termostatu i konfigurację czujników. Bieżącą temperaturę, profil i tryb zmieniaj w „Szybkich termostatach” na Pulpicie.",
@@ -538,10 +539,10 @@
"house.noControl": "Nie steruj",
"house.powerAllEyebrow": "Zasilanie globalne",
"house.powerAllTitle": "Wszystkie klimatyzatory",
"house.powerAllHint": "Nadrzędne zasilanie wszystkich aktywnych jednostek. „Wyłącz wszystkie” blokuje ich ponowne uruchamianie. Globalny wybór Chłodzenia/Grzania albo profilu Auto/Komfort/Sen/Poza domem automatycznie ustawia „Włącz wszystkie”.",
"house.powerAllHint": "Nadrzędne zasilanie stref. „Wyłącz wszystkie” wyłącza też wszystkie grupy i jednostki. „Włącz wszystkie” uruchamia tylko włączone strefy — strefy wyłączone pozostają wyłączone. Globalny wybór Chłodzenia/Grzania albo profilu automatycznie włącza zasilanie globalne.",
"house.powerOnAll": "Włącz wszystkie",
"house.powerOffAll": "Wyłącz wszystkie",
"house.powerOnDone": "Włączono wszystkie klimatyzatory",
"house.powerOnDone": "Włączono wszystkie aktywne strefy",
"house.powerOffDone": "Wyłączono wszystkie klimatyzatory",
"house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń",
"preset.manual": "Ręcznie",
@@ -586,6 +587,10 @@
"zones.controlNow": "Steruj",
"zones.noGroup": "bez grupy",
"zones.coolOnly": "Tylko chłodzenie",
"zones.heatOnly": "Tylko grzanie"
"zones.heatOnly": "Tylko grzanie",
"zones.manualDeviceControl": "Sterowanie ręczne / pilot",
"zones.manualDeviceUntil": "Sterowanie ręczne / pilot do {time}",
"zones.manualDeviceNoBoundary": "Sterowanie ręczne / pilot · do wznowienia",
"zones.resumeAutomation": "Wznów automatykę"
}
}
+69 -10
View File
@@ -78,7 +78,7 @@ pub fn router(state: AppState) -> Router {
let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_home_assistant_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups))
.route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control))
@@ -413,7 +413,14 @@ async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> R
}
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
Ok(Json(engine::send_command(&state, &id, command).await?))
Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?))
}
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) {
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into()));
}
Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?))
}
#[derive(Debug, Deserialize)]
@@ -509,6 +516,7 @@ impl ZoneInput {
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(),
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -552,6 +560,10 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.manual_preset = existing.manual_preset;
zone.manual_setpoint = existing.manual_setpoint;
zone.manual_override_until = existing.manual_override_until;
zone.device_manual_override = existing.device_manual_override;
zone.device_manual_override_since = existing.device_manual_override_since;
zone.device_manual_override_until = existing.device_manual_override_until;
zone.device_manual_override_fields = existing.device_manual_override_fields;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
@@ -561,13 +573,22 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.last_action_at = existing.last_action_at;
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
let power_off_device = existing.enabled && !zone.enabled;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if power_off_device {
power_off_zone_device(&state, &zone, "zone.disabled").await;
}
Ok(Json(zone))
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let was_enabled = zone.enabled;
let schedules = state.db.list_schedules()?;
// Any explicit thermostat action means the user is handing ownership back to the zone
// controller. This includes the dedicated Resume automation button.
let resume_device_automation = patch.clear_device_manual_override.unwrap_or(false)
|| patch.setpoint.is_some() || patch.mode.is_some() || patch.preset.is_some() || patch.enabled.is_some();
if let Some(value) = patch.setpoint {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
@@ -608,17 +629,35 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if was_enabled && !zone.enabled {
power_off_zone_device(&state, &zone, "zone.quick_disabled").await;
}
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
"override_until": zone.manual_override_until, "enabled": zone.enabled
"override_until": zone.manual_override_until, "enabled": zone.enabled,
"device_manual_override_cleared": device_override_cleared
}));
Ok(Json(zone))
}
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
if !device.enabled || !device.power { return; }
if let Err(err) = engine::send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
state.log("error", "zone.disable_power_error", &err.to_string(), json!({
"zone_id": zone.id,
"device_id": device.id,
"device_name": device.name,
"source": source,
}));
}
}
#[derive(Debug, Deserialize)]
struct GroupInput {
name: String,
@@ -825,6 +864,8 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"demand": zone.demand,
"control_source": zone.control_source,
"current_schedule": zone.current_schedule_name,
"device_manual_override": zone.device_manual_override,
"device_manual_override_until": zone.device_manual_override_until,
})).collect::<Vec<_>>();
output.push(json!({
@@ -863,10 +904,10 @@ async fn update_home_assistant_group_control(
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
for mut group in state.db.list_groups()? {
if group.power_enabled { continue; }
group.power_enabled = true;
if group.power_enabled == power { continue; }
group.power_enabled = power;
group.updated_at = Utc::now();
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
@@ -876,8 +917,19 @@ fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
let enabled_zone_devices: std::collections::HashSet<String> = if power {
state.db.list_zones()?.into_iter()
.filter(|zone| zone.enabled && !zone.device_manual_override)
.map(|zone| zone.device_id)
.collect()
} else {
std::collections::HashSet::new()
};
for device in state.db.list_devices()? {
if !device.enabled || device.power == power { continue; }
// Whole-house ON only operates thermostat-managed, enabled zones. Devices with
// a disabled zone (or no zone at all) remain manual/technical Devices controls.
if power && !enabled_zone_devices.contains(&device.id) { continue; }
let command = DeviceCommand { power: Some(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!({
@@ -914,7 +966,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
};
state.broadcast("settings.updated", payload.clone());
if activate_all {
enable_all_groups(&state)?;
set_all_groups_power(&state, true)?;
let failed = command_all_enabled_devices_power(&state, true, "house_mode").await?;
if !failed.is_empty() {
state.log("warn", "house.mode_power_partial", "House mode enabled master power, but some devices could not be powered on", json!({
@@ -933,6 +985,9 @@ 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.
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
}
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
@@ -943,20 +998,24 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
}
}
if input.power { enable_all_groups(&state)?; }
// Global power is a true cascade across group gates. Disabled thermostat zones stay
// disabled and are therefore not powered by the ON direction of this command.
set_all_groups_power(&state, input.power)?;
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
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!({
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled thermostat zones powered on" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
Ok(Json(json!({
"power": input.power,
"devices": devices,
"groups": groups,
"settings": settings_payload,
"failed": failed,
})))
@@ -979,7 +1038,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
public_settings(&settings)
};
state.broadcast("settings.updated", settings_payload.clone());
enable_all_groups(&state)?;
set_all_groups_power(&state, true)?;
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;
+304 -29
View File
@@ -178,7 +178,14 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let before = device.clone();
poll_device(state, &mut device).await;
let concurrent_command = state.db.get_device(device_id)?
.map(|current| current.updated_at > before.updated_at)
.unwrap_or(false);
if !concurrent_command && poll_completed_successfully(&device) {
detect_external_device_control(state, &before, &device)?;
}
state.db.save_device(&device)?;
record_reading(state, &device)?;
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
@@ -188,7 +195,14 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppEr
async fn poll_all(state: &AppState) -> Result<()> {
for mut device in state.db.list_devices()? {
if !device.enabled { continue; }
let before = device.clone();
poll_device(state, &mut device).await;
let concurrent_command = state.db.get_device(&device.id)?
.map(|current| current.updated_at > before.updated_at)
.unwrap_or(false);
if !concurrent_command && poll_completed_successfully(&device) {
detect_external_device_control(state, &before, &device)?;
}
state.db.save_device(&device)?;
record_reading(state, &device)?;
state.broadcast("device.updated", serde_json::to_value(&device)?);
@@ -331,6 +345,138 @@ pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError>
Ok(())
}
fn poll_completed_successfully(device: &Device) -> bool {
device.online && device.communication_failures == 0 && device.last_error.is_none()
}
fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
let mut fields = Vec::new();
if command.power.is_some() { fields.push("power".to_string()); }
if command.mode.is_some() { fields.push("mode".to_string()); }
if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); }
if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); }
if command.quiet.is_some() { fields.push("quiet".to_string()); }
if command.sleep.is_some() { fields.push("sleep".to_string()); }
fields
}
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
let mut fields = Vec::new();
if before.power != after.power { fields.push("power".to_string()); }
if before.mode != after.mode { fields.push("mode".to_string()); }
if (before.target_temperature - after.target_temperature).abs() >= 0.5 {
fields.push("target_temperature".to_string());
}
// Some GREE units accept the controller's standby Low fan hint and later report Auto
// again without user interaction. Treat that one known normalization as firmware drift,
// not as a remote-control takeover. Other fan changes remain meaningful manual input.
let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
if before.fan_speed != after.fan_speed && !standby_low_to_auto {
fields.push("fan_speed".to_string());
}
fields
}
pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
let changed = zone.device_manual_override
|| zone.device_manual_override_since.is_some()
|| zone.device_manual_override_until.is_some()
|| !zone.device_manual_override_fields.is_empty();
zone.device_manual_override = false;
zone.device_manual_override_since = None;
zone.device_manual_override_until = None;
zone.device_manual_override_fields.clear();
changed
}
fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<String>, source: &str) -> Result<(), AppError> {
if fields.is_empty() { return Ok(()); }
let now = Utc::now();
if !zone.device_manual_override {
zone.device_manual_override_since = Some(now);
}
zone.device_manual_override = true;
zone.device_manual_override_until = if zone.enabled {
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
} else {
None
};
zone.device_manual_override_fields = fields.clone();
zone.demand = false;
zone.demand_since = None;
zone.updated_at = now;
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({
"zone_id": zone.id,
"device_id": zone.device_id,
"fields": fields,
"source": source,
"override_until": zone.device_manual_override_until,
}));
Ok(())
}
fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
if before.id != after.id { return Ok(()); }
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
let fields = externally_changed_control_fields(before, after, &zone);
if fields.is_empty() { continue; }
// A disabled zone is outside controller ownership. When its manually operated unit is
// switched off there is no takeover left to display or remember.
if !zone.enabled && !after.power {
if reset_device_manual_override(&mut zone) {
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.device_manual_override_cleared", &format!("Manual device control ended for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "source": "gree_poll"
}));
}
continue;
}
set_device_manual_override(state, &mut zone, fields, "gree_poll")?;
}
Ok(())
}
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
let before = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let effective_command = if before.online { command.changed_from(&before) } else { command.clone() };
let fields = command_manual_control_fields(&effective_command);
let updated = send_command(state, device_id, command).await?;
if !fields.is_empty() {
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
if !zone.enabled && !updated.power {
if reset_device_manual_override(&mut zone) {
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
continue;
}
set_device_manual_override(state, &mut zone, fields.clone(), source)?;
}
}
Ok(updated)
}
pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result<usize, AppError> {
let mut cleared = 0usize;
for mut zone in state.db.list_zones()? {
if !reset_device_manual_override(&mut zone) { continue; }
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "source": source
}));
cleared += 1;
}
Ok(cleared)
}
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
if let Some(mode) = patch.mode.as_deref() {
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
@@ -398,10 +544,12 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let mut seen = std::collections::HashSet::new();
for zone in &zones {
if !seen.insert(zone.device_id.clone()) { continue; }
// Group actions never own disabled zones and never override an active manual/pilot
// takeover. Whole-house OFF is handled separately and remains authoritative.
if !zone.enabled || zone.device_manual_override { continue; }
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
if !device.enabled || device.power == desired_power { continue; }
if desired_power {
if !zone.enabled { continue; }
let blocked_by_other_group = group_snapshot.iter().any(|other| {
other.id != group.id && !other.power_enabled && other.zone_ids.iter().any(|zone_id| zone_id == &zone.id)
});
@@ -480,6 +628,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
if !settings.house_power_enabled {
// Whole-house OFF is the one deliberate authority above manual/pilot takeover.
// Clear remembered takeovers as well, so a later whole-house ON starts cleanly.
clear_all_device_manual_overrides(state, "house_master_off")?;
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 {
@@ -495,6 +646,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
reset_device_manual_override(&mut zone);
state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
let Some(device) = state.db.get_device(&zone.device_id)? else {
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
@@ -543,6 +700,35 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
// A disabled thermostat zone is completely outside normal controller ownership.
// Keep its sensors fresh, but do not let group state, schedules or thermostat
// modulation touch the unit. Manual control from the technical Devices view may
// therefore remain active until the zone is explicitly enabled again.
if !zone.enabled {
zone.demand = false;
zone.demand_since = 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)?);
continue;
}
// A physical/manual takeover has higher priority than thermostat, schedule, group and
// automation control. Continue sensor/history updates, but reflect the unit's real state
// instead of sending corrective frames that would fight the person holding the remote.
if zone.device_manual_override {
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
zone.effective_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.demand = false;
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)?);
continue;
}
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
if blocked_by_group {
zone.effective_mode = "off".into();
@@ -570,17 +756,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
}));
}
// Disabled zones still refresh and publish their room temperature. Disabling a
// thermostat stops control actions, but it must not make the room sensor disappear
// from the dashboard or zone view.
if !zone.enabled {
zone.demand = false;
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)?);
continue;
}
// 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.
@@ -655,6 +830,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
zone.device_setpoint = Some(desired_device_target);
let demand_changed = previous_demand != zone.demand;
let desired_fan = if night_active {
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
if zone.smart_fan {
@@ -694,10 +870,18 @@ async fn control_zones(state: &AppState) -> Result<()> {
device.sleep,
);
let needs_command = !device.power
let core_needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false)
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
// In normal standby, Low fan is a transition hint rather than a state that should
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
// again; retrying every min_adjust_seconds only causes needless command beeps.
let fan_needs_command = desired_fan
.map(|fan| fan != device.fan_speed)
.unwrap_or(false)
&& (zone.demand || demand_changed || core_needs_command || night_active);
let needs_command = core_needs_command
|| fan_needs_command
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
@@ -707,7 +891,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
power: Some(true),
mode: Some(effective_mode.to_string()),
target_temperature: Some(desired_device_target),
fan_speed: desired_fan,
fan_speed: if fan_needs_command { desired_fan } else { None },
quiet: desired_quiet,
sleep: desired_sleep,
..Default::default()
@@ -914,13 +1098,20 @@ fn smart_quiet_command(
night_force_quiet: bool,
) -> Option<bool> {
if !quiet_supported { return None; }
if night_enabled && night_force_quiet {
if night_active { return Some(true); }
if device_quiet { return Some(false); }
if night_enabled && night_force_quiet && night_active {
return if device_quiet { None } else { Some(true) };
}
if !smart_fan { return None; }
if !demand { return Some(true); }
if !previous_demand && device_quiet { return Some(false); }
if smart_fan {
// Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a
// satisfied room remains in standby: some units report Quiet=false again even after
// accepting the command, which otherwise produces a beep every adjustment interval.
if previous_demand && !demand && !device_quiet { return Some(true); }
if !previous_demand && demand && device_quiet { return Some(false); }
return None;
}
// Without Smart Fan, Quiet can only have been requested by scheduled night mode,
// so release it after the night window ends.
if night_enabled && night_force_quiet && device_quiet { return Some(false); }
None
}
@@ -1120,7 +1311,14 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone.mode.as_str()
};
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
let effective_mode = if blocked_by_group { "off" } else { configured_effective_mode };
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
let effective_mode = if zone.device_manual_override {
manual_device_mode.unwrap_or(configured_effective_mode)
} else if blocked_by_group {
"off"
} else {
configured_effective_mode
};
// Keep the thermostat target readable even while the zone/group/house control is off.
// Home Assistant climate entities otherwise expose target_temperature as unknown.
@@ -1143,21 +1341,25 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone_name: zone.name.clone(),
device_id: zone.device_id.clone(),
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled && !blocked_by_group,
enabled: zone.enabled && (!blocked_by_group || zone.device_manual_override),
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if zone.active_preset.is_empty() { resolved_preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if !zone.enabled || effective_mode == "off" {
target_temperature: if zone.device_manual_override {
device.filter(|item| item.power).map(|item| item.target_temperature).or(Some(resolved_target))
} else if !zone.enabled || effective_mode == "off" {
Some(resolved_target)
} else {
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && zone.demand,
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
device_manual_override: zone.device_manual_override,
device_manual_override_until: zone.device_manual_override_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
next_events,
@@ -1324,9 +1526,29 @@ async fn run_automations(state: &AppState) -> Result<()> {
_ => false,
};
if !should_fire { continue; }
if item.action_group_id.is_none() && device_blocked_by_disabled_zone(&item.action_device_id, &zones) {
// Disabled thermostat zones are outside normal automation. The underlying
// unit can still be operated manually from the technical Devices view.
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id
}));
continue;
}
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
// Group power-off is authoritative. Suppress a raw-device automation instead of
// waking the unit for one control cycle and immediately switching it off again.
// Group power-off is authoritative for normal controller-owned zones. A manual
// takeover is filtered above and therefore remains higher priority than the group.
item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
@@ -1367,6 +1589,14 @@ async fn run_automations(state: &AppState) -> Result<()> {
Ok(())
}
fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled)
}
fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override)
}
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
let zone_ids: std::collections::HashSet<&str> = zones.iter()
@@ -1508,11 +1738,53 @@ mod tests {
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
manual_preset: None, manual_setpoint: None, manual_override_until: None,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(),
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
}
}
#[test]
fn external_device_change_detects_manual_climate_controls() {
let zone = test_zone("device");
let before = Device::simulated_default();
let mut after = before.clone();
after.power = !before.power;
after.target_temperature = before.target_temperature + 1.0;
after.fan_speed = 3;
let fields = externally_changed_control_fields(&before, &after, &zone);
assert!(fields.iter().any(|field| field == "power"));
assert!(fields.iter().any(|field| field == "target_temperature"));
assert!(fields.iter().any(|field| field == "fan_speed"));
}
#[test]
fn standby_low_to_auto_fan_drift_is_not_manual_override() {
let mut zone = test_zone("device");
zone.smart_fan = true;
zone.demand = false;
let mut before = Device::simulated_default();
before.fan_speed = 1;
let mut after = before.clone();
after.fan_speed = 0;
assert!(externally_changed_control_fields(&before, &after, &zone).is_empty());
}
#[test]
fn reset_device_manual_override_clears_takeover_state() {
let mut zone = test_zone("device");
zone.device_manual_override = true;
zone.device_manual_override_since = Some(Utc::now());
zone.device_manual_override_until = Some(Utc::now());
zone.device_manual_override_fields = vec!["target_temperature".into()];
assert!(reset_device_manual_override(&mut zone));
assert!(!zone.device_manual_override);
assert!(zone.device_manual_override_since.is_none());
assert!(zone.device_manual_override_until.is_none());
assert!(zone.device_manual_override_fields.is_empty());
}
#[test]
fn device_command_drops_unchanged_fields() {
let device = Device::simulated_default();
@@ -1603,6 +1875,8 @@ mod tests {
#[test]
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true));
assert_eq!(smart_quiet_command(true, true, false, false, false, false, false, true), None);
assert_eq!(smart_quiet_command(true, true, false, false, true, false, false, true), None);
assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false));
assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None);
assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None);
@@ -1619,6 +1893,7 @@ mod tests {
assert_eq!(night_limited_fan_speed(3, 1), 1);
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
assert_eq!(smart_quiet_command(true, true, false, false, true, true, false, true), None);
assert_eq!(native_sleep_command(true, true, true, true, false), Some(true));
assert_eq!(native_sleep_command(true, false, true, true, true), Some(false));
assert_eq!(native_sleep_command(true, true, true, false, false), None);
+16
View File
@@ -322,6 +322,17 @@ pub struct Zone {
pub manual_setpoint: Option<f64>,
#[serde(default)]
pub manual_override_until: Option<DateTime<Utc>>,
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
#[serde(default)]
pub device_manual_override: bool,
#[serde(default)]
pub device_manual_override_since: Option<DateTime<Utc>>,
#[serde(default)]
pub device_manual_override_until: Option<DateTime<Utc>>,
/// Climate-relevant fields that caused the most recent external/manual takeover.
#[serde(default)]
pub device_manual_override_fields: Vec<String>,
#[serde(default)]
pub effective_mode: String,
#[serde(default)]
@@ -380,6 +391,9 @@ pub struct ZoneControlPatch {
pub preset: Option<String>,
#[serde(default)]
pub clear_override: Option<bool>,
/// Explicitly hand control of a manually overridden physical unit back to the thermostat engine.
#[serde(default)]
pub clear_device_manual_override: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -680,6 +694,8 @@ pub struct ZoneControlPlan {
pub demand: bool,
pub control_source: String,
pub manual_override_until: Option<DateTime<Utc>>,
pub device_manual_override: bool,
pub device_manual_override_until: Option<DateTime<Utc>>,
pub current_schedule_id: Option<String>,
pub current_schedule_name: Option<String>,
pub next_events: Vec<ControlPlanEvent>,
+38 -13
View File
@@ -314,7 +314,10 @@ function renderControlPlan() {
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
const groupNames = zoneGroups.get(zone.zone_id) || [];
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
return `<article class="panel plan-card plan-zone ${zone.enabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</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>`;
const planState = zone.device_manual_override
? tr('zones.manualDeviceControl')
: (zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'));
return `<article class="panel plan-card plan-zone ${zone.enabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (zone.enabled && zone.demand ? 'active' : '')}">${esc(planState)}</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);
@@ -614,6 +617,10 @@ function deviceFeaturePanel(device) {
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field,,label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
}
function deviceZoneDisabled(deviceId) {
return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false);
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
@@ -621,25 +628,28 @@ function deviceCard(device, detailed = false) {
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>`;
const zoneLocked = !detailed && deviceZoneDisabled(device.id);
const locked = zoneLocked ? ' disabled' : '';
const quickClass = detailed ? '' : ' quick-control-card quick-device-control';
const quickRow = detailed ? '' : ' quick-control-row';
return `<article class="device-card${quickClass} ${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>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"></button>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"${locked}></button>
</div>
<div class="temperature-control">
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"></button>
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"${locked}></button>
<div class="target-temp">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}">+</button>
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}"${locked}>+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row${quickRow}${detailed ? '' : ' quick-control-row-4'}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
${zoneLocked ? `<div class="field-note warning-note">${esc(tr('devices.disabledZoneTechnicalOnly'))}</div>` : ''}
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}"${locked}>${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row${quickRow}${detailed ? '' : ' quick-control-row-4'}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}"${locked}>${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles${quickRow}${detailed ? '' : ' quick-control-row-3'}">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.swing'))}</button>
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.turbo'))}</button>`}
</div>
${detailed ? deviceFeaturePanel(device) : ''}
${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>` : ''}
@@ -688,9 +698,17 @@ function zoneCard(zone, detailed = true) {
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off';
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
const override = zone.manual_override_until
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl'));
const deviceManualOverride = zone.device_manual_override === true;
const override = deviceManualOverride
? (zone.device_manual_override_until
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
: tr('zones.manualDeviceNoBoundary'))
: (zone.manual_override_until
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
const manualTakeover = deviceManualOverride
? `<div class="manual-override-row"><span class="badge manual-override">${esc(tr('zones.manualDeviceControl'))}</span><button type="button" data-action="zone-resume-automation" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
: '';
if (detailed) {
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
@@ -709,6 +727,7 @@ function zoneCard(zone, detailed = true) {
<div><small>${esc(tr('zones.smartFan'))}</small><strong>${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}</strong></div>
</div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
${manualTakeover}
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`;
}
@@ -720,7 +739,8 @@ function zoneCard(zone, detailed = true) {
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
</div>
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house">${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
<div class="zone-state-line"><span>${zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied'))}</span><span>${esc(override)}</span></div>
<div class="zone-state-line"><span>${deviceManualOverride ? esc(tr('zones.manualDeviceControl')) : (zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied')))}</span><span>${esc(override)}</span></div>
${manualTakeover}
</article>`;
}
@@ -1657,6 +1677,9 @@ document.addEventListener('click', async event => {
}
const action = button.dataset.action; if (!action) return;
const device = app.devices.find(v => v.id === button.dataset.device);
if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) {
return toast(tr('devices.disabledZoneTechnicalOnly'), true);
}
if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power});
if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)});
if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true});
@@ -1677,6 +1700,7 @@ document.addEventListener('click', async event => {
const result = await api('/api/house/power',{method:'POST',body:{power}});
app.settings = result.settings || app.settings;
app.devices = result.devices || app.devices;
app.groups = result.groups || app.groups;
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (failed) toast(tr('house.powerPartial', {count: failed}), true);
@@ -1702,6 +1726,7 @@ document.addEventListener('click', async event => {
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'});
if (action === 'zone-resume-automation') return sendZoneControl(button.dataset.id,{clear_device_manual_override:true});
if (action === 'zone-go-control') {
showView('dashboard', {scroll:false});
const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id);
+1 -1
View File
@@ -38,7 +38,7 @@
</div>
<div class="panel dashboard-global-panel">
<div class="dashboard-global-power">
<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">Master power for all enabled units. Whole-house mode or profile selection automatically switches it back on.</p></div>
<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">Master power for thermostat zones. Turn all off also disables all groups; turn all on starts enabled zones only.</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="dashboard-global-divider"></div>
+3
View File
@@ -160,6 +160,9 @@ h3 { margin-bottom: 10px; }
.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.badge.manual-override { color: var(--warning); background: var(--warning-soft); }
.manual-override-row { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; padding:8px 10px; border-radius:10px; background:var(--warning-soft); }
.manual-override-row button { flex:0 0 auto; min-height:30px; padding:5px 9px; }
.zone-enable-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
.zone-enable-toggle.active { border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.zone-enable-toggle span { font-size: 12px; font-weight: 900; }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v073-ha-groups';
const CACHE = 'gree-controller-v074-manual-override';
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')];