This commit is contained in:
Mateusz Gruszczyński
2026-08-27 14:16:56 +02:00
parent 584bdca7c6
commit 7102d05cb3
21 changed files with 305 additions and 65 deletions
+26 -2
View File
@@ -1,8 +1,8 @@
# GREE Controller v0.7.8 - build and validation report
# GREE Controller v0.7.9 - build and validation report
## Scope
Version 0.6.7 includes the previous climate-group and whole-house changes plus a reorganized main dashboard. Global controls are consolidated, group control stays immediately below them, and lower-priority dashboard sections are collapsed on demand.
Version 0.7.9 includes the previous climate-group and whole-house changes plus the zone-profile and Home Assistant stability fixes described below. Global controls are consolidated, group control stays immediately below them, and lower-priority dashboard sections are collapsed on demand.
## Implemented changes
@@ -381,3 +381,27 @@ Rust compiler note: this packaging environment still does not provide `cargo`, `
- Replaced the misleading thermostat runtime labels **Potrzeba komfortu** / **Komfort / standby inwertera** with **Zapotrzebowanie aktywne** / **Cel osiągnięty**. The Comfort wording remains reserved for the actual Comfort preset/profile. Runtime status also reports **Oczekiwanie** when thermostat control is paused by mode and **Brak pomiaru** when no valid control temperature is available, so **Cel osiągnięty** is not used as a generic idle state.
- Simplified per-unit manual power in Dashboard -> Quick thermostats: removed the redundant **Jednostka · pilot** caption and the separate ON/OFF pair. A single contextual **Włącz jednostkę** / **Wyłącz jednostkę** button now directly controls only that physical unit with the existing manual-takeover semantics.
- Bumped package/Home Assistant metadata to `0.7.8` and rotated the PWA cache key.
## 2026-08-27 v0.7.9 zone profile and Home Assistant fixes
- Explicit quick-zone target/mode/profile/enable actions now refresh the runtime profile target immediately and wake the regulator instead of waiting for the next fixed thermostat interval.
- Manual/remote takeover no longer exposes a physical standby target (for example 25 C) as the zone thermostat target. The thermostat profile target and physical `device_setpoint` are reported separately.
- Control-plan `enabled` is now the configured per-zone switch; new `effective_enabled` reports disabled-group gating without making the Home Assistant zone switch bounce to Off.
- Home Assistant zone commands have a short pending-state guard against stale overlapping refreshes.
- Zone climate entities support Auto/Comfort/Sleep/Away presets and each zone device has a dedicated **Work profile** select.
- Added a focused Rust unit test for immediate manual-profile target refresh.
Validation for this package is recorded after the final archive/manifest checks. Rust compile/test still requires the normal target/CI host because this packaging environment has no Rust toolchain.
## Final validation for v0.7.9
- `node --check` passed for all Web UI JavaScript files.
- Home Assistant modules, helper scripts and `make_zip.py` passed `python3 -m py_compile`.
- All JSON files parsed successfully; Polish and English language packs have identical flattened 597-key sets.
- All shell scripts passed `bash -n`; `web/index.html` parsed successfully and CSS braces are balanced.
- Cargo package, Home Assistant manifest, README and PWA cache metadata consistently report version `0.7.9`.
- Rust source delimiter/static invariant checks passed, including the new regulator wake-up and control-plan fields.
- `FILE_MANIFEST.sha256` is regenerated from the final tree and verified before packaging; the resulting ZIP is tested with `unzip -t`.
- This environment does not provide `cargo`, `rustc` or `rustfmt`, so the normal target/CI update path must still 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.8"
version = "0.7.9"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.7.8"
version = "0.7.9"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+20 -20
View File
@@ -1,33 +1,33 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
ff0beb45aafde9e919deaa38a8900c8bc09ae5a853e1a17ef6a2b596eaa38a4e ./BUILD_REPORT.md
8d644882219821d356fdc1e65f2dfd6e15e0ac6ad4d83b6cc9e99b914d28afe1 ./Cargo.lock
5f372aa21533c57499cb6ce87db5708e5127bd8021b1113a0cc5393a8c74dd8e ./Cargo.toml
eb1d764a4d0622db45bcdfde3e7c94075707a375ae62c3e316678fce86838551 ./BUILD_REPORT.md
e07c746bbf859c3a36e48d27fd381c29e2c56086fa1df68ecc30eea8a73fc335 ./Cargo.lock
bc5a32e369cbf84dfb12faa2f0a70c53e2034ade769c0dfa88b04b645a755864 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
8d296b79780b105cce00d1ca447727316bb3363984129740ac1c6d3868b3b0b6 ./README.md
46b7e09ab96f428ff6d5eb8a95a340d827af7f986176ef35c5a3e3111e3bf7d3 ./README.md
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
8e72ed628055338d8e9ecc3d9f6929f225fc1ce00fa2e0f19765594a1bcb824f ./docs/API.md
f19897119089a4c6415bc35368eb9e5607aa7cafc6360c87812b1c662f30fadf ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
ada2df9ae4ca93280c71c932849e452fbcae045509f248ca34c9f92bd44400a3 ./home-assistant/README.md
21e899f338b5f1d8267a0096293750cf240e4b35a893c9680af56515ad9f7320 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
33e5756ff6ca102c9738c61c5119b060a661125dd919bb32e9c1dc04ef4d2c2c ./home-assistant/custom_components/gree_controller/climate.py
8c10cb10b31357d9eee371e1d1c078b15fb27b2c2cdbdbac35b049762737de22 ./home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py
f31f3909f03fe4be105f89bcb4fe86331b01757288ac342ed0d26b35422d6387 ./home-assistant/custom_components/gree_controller/coordinator.py
923f68c5ee8def4443749d891599d7176e1a38c5a99552ccbe3502e34b425925 ./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
f37d31b0b823a6ee516ebc34599c52e19dafcf1e7074253d08913587f3c5294c ./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
2fd8c9ed7a7d24954fb80b7ed9d6426729a19c8342c754aa119b20a18e4ef0eb ./home-assistant/custom_components/gree_controller/switch.py
67c87a0dbc7a3ed7ad617ddbd6996de7b6c4a40653745ad310ec0cfe36860e45 ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py
229ef9b3de8001bdedea42015a219c7f7c17d50f90b10705508494d434aeb549 ./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
@@ -46,26 +46,26 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
65f639252d59eb8e9a5be7f1731768d607f49c9cbe80e005382dabcc3fd828ea ./src/api.rs
1f131ce502b6e11057e0522d44fec484ac0af769a6af6c4a0c7fa420567faad9 ./src/api.rs
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
8585ea7b31e12974907341e959eb98fc1bc5c3993e3b9ccf9f3b2f676c10b953 ./src/engine.rs
780ba1214043b2894232eebebc9c89dcbfd33cd4f6d263baacb4c42bbcc6692e ./src/engine.rs
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
2aead1459f334d54970c66f9fb1f04d184f85082525d313cf4d727bdcc089578 ./src/main.rs
82c5c7fb4f8f110583546a3c9134e40f91dbc6ff1abf7cbb21f0adbafaafdd15 ./src/models.rs
708353b36fd1604c10fd81898c1b7abf463ee1271544e27f38a4b51291601b36 ./src/main.rs
20268f5bc184eee0ce7272ffc52c07ff5909eea2f866f798a20f4a4620fbac3c ./src/models.rs
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
a60bd8d23b78c74447546722a03ff11c75367468d3bab9f2f5f9cb56f8e09572 ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
48a001996a8e0f7ab847fad062f6973173fce2766bdfa50cbd49c07bcf628fa5 ./src/state.rs
115a386a76db7e6182bae58067d43c11ef5bf9aae6fe9ac149f13c7191a5812a ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
b04c9a8d013a1d512cb5246b76ae84ca20770a4561cfa23f51109db01e6a468f ./web/app.js
59aa9e04b0251775ee6460e04989720866756fde7cc1378fa0f09fa4b6d00be1 ./web/app.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
d4369e34b5392be142c038f5fc68343bd3514a8b34aa8503f1ed2ed700737299 ./web/styles.css
47189dc2da72f976e5e868fcc82284f7ee45b47747812ac08f54872e6d5b758d ./web/sw.js
3ec8109e706047d95387deb11af80164e97d5a6f0ad93cc70a1a71d6eae194a0 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+10 -2
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.8**.
Current version: **0.7.9**.
## 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.8.zip
unzip gree-controller-v0.7.9.zip
cd gree-controller
chmod +x scripts/*.sh
./scripts/dev.sh
@@ -477,6 +477,14 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C
- Rotated the PWA cache key so browsers fetch the corrected stylesheet after upgrade.
## 0.7.9 Zone profile control and Home Assistant stability
- Quick thermostat profile/mode/target/enable actions now wake the thermostat regulator immediately instead of waiting for the next fixed zone interval.
- A manual/remote takeover keeps the configured thermostat profile target visible; the physical GREE target is reported separately as the device setpoint. This prevents standby setpoints such as 25 C from being shown as the Sleep/Comfort target.
- Control-plan `enabled` now represents the configured per-zone switch. A new `effective_enabled` field reports group gating, so Home Assistant zone switches/climate entities no longer bounce back to Off solely because a climate group is disabled.
- Home Assistant zone controls use a short pending-state guard, matching the existing physical-device anti-bounce protection.
- Every zone now exposes a **Work profile** select and the zone `climate` entity supports preset modes: Auto schedule, Comfort, Sleep and Away.
## 0.7.8 Zone status wording and unit power
- Zone runtime status now says **Zapotrzebowanie aktywne** when the thermostat is requesting work and **Cel osiągnięty** when the target/hysteresis condition is satisfied; **Komfort** remains only the preset name. Paused control is shown as **Oczekiwanie**, and missing control temperature as **Brak pomiaru**.
+1 -1
View File
@@ -211,7 +211,7 @@ When cooling is driven by a valid per-zone Home Assistant room sensor (`external
On process startup the thermostat/schedule/automation engine stays command-passive until one full live poll of all enabled GREE units completes. Persisted device/zone/group state is still available immediately for the UI, but it is not used to emit climate commands before the physical state has been synchronized.
`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.
`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 configured `enabled` state, group-gated `effective_enabled` state, effective `mode`, `configured_mode`, `inherit_house_mode`, resolved `preset`, explicit `preset_override` (`null` = Auto schedule), current/target temperatures, physical `device_setpoint`, 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. During physical/manual takeover, `target_temperature` remains the thermostat profile/schedule target while `device_setpoint` reports the actual GREE target; automation remains paused until resumed or another explicit zone control is sent.
```bash
curl "$BASE/api/control-plan" -H "$AUTH"
+3 -1
View File
@@ -122,6 +122,8 @@ The recommended `combined` strategy keeps the GREE sensor as the primary input a
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group.
From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
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.
@@ -153,4 +155,4 @@ After upgrading the custom integration, restart Home Assistant or reload **Setti
## Command state stability (0.7.6)
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
@@ -265,8 +265,10 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
_attr_max_temp = 30.0
_attr_target_temperature_step = 0.5
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
_attr_preset_modes = ["auto", "comfort", "sleep", "away"]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
@@ -317,15 +319,21 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
return HVACMode.AUTO
return HVACMode.HEAT if str(zone.get("configured_mode", zone.get("mode", "cool"))) == "heat" else HVACMode.COOL
@property
def preset_mode(self) -> str:
return str(self._zone.get("preset_override") or "auto")
@property
def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone
return {
"controller_zone_id": self._zone_id,
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"effective_mode": zone.get("mode"),
"follows_house_mode": bool(zone.get("inherit_house_mode", True)),
"auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode",
"preset": zone.get("preset"),
"active_preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"demand": bool(zone.get("demand", False)),
"device_setpoint": zone.get("device_setpoint"),
"control_source": zone.get("control_source"),
@@ -343,21 +351,22 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
payload = {"enabled": True, "mode": "heat"}
else:
return
await self.coordinator.client.zone_control(self._zone_id, payload)
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, payload)
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(temperature)})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(temperature)})
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode not in self._attr_preset_modes:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
async def async_turn_on(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": True})
async def async_turn_off(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
@@ -34,6 +34,9 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
# Protect a just-accepted device command from an overlapping/stale poll.
# GREE units can expose their previous status briefly after acknowledging a write.
self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {}
# Zone switches/climate/profile controls need the same protection. A coordinator refresh
# that started before the POST must not make an accepted zone action visibly bounce back.
self._pending_zone_controls: dict[str, tuple[float, dict[str, Any]]] = {}
async def _async_update_data(self) -> dict[str, dict]:
try:
@@ -44,6 +47,7 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
)
except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err
self._overlay_pending_zone_controls(plan)
self.plan = plan
self.groups = {str(group["id"]): group for group in groups if group.get("id")}
device_map = {str(device["id"]): device for device in devices if device.get("id")}
@@ -77,6 +81,68 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
if device is not None:
device.update(expected)
@staticmethod
def _normalized_zone_control(payload: dict[str, Any]) -> dict[str, Any]:
"""Map zone-control API fields to the Home Assistant control-plan representation."""
expected: dict[str, Any] = {}
if "enabled" in payload:
expected["enabled"] = bool(payload["enabled"])
if "mode" in payload:
mode = str(payload["mode"])
if mode in {"house", "auto"}:
expected["inherit_house_mode"] = True
elif mode in {"cool", "heat"}:
expected["inherit_house_mode"] = False
expected["configured_mode"] = mode
expected["mode"] = mode
if "preset" in payload:
preset = str(payload["preset"])
expected["preset_override"] = None if preset == "auto" else preset
if preset != "auto":
expected["preset"] = preset
if "setpoint" in payload:
value = max(8.0, min(30.0, float(payload["setpoint"])))
expected["target_temperature"] = round(value * 2.0) / 2.0
if payload.get("clear_override"):
expected["preset_override"] = None
return expected
def _overlay_pending_zone_controls(self, plan: dict[str, Any]) -> None:
"""Overlay accepted zone controls onto stale/in-flight control-plan reads."""
now = asyncio.get_running_loop().time()
zones = plan.get("zones", [])
for zone_id, (deadline, expected) in list(self._pending_zone_controls.items()):
if now >= deadline:
self._pending_zone_controls.pop(zone_id, None)
continue
for zone in zones:
if str(zone.get("zone_id")) == zone_id:
zone.update(expected)
break
async def async_zone_control(self, zone_id: str, payload: dict[str, Any]) -> None:
"""Send a thermostat-zone command with a short optimistic anti-bounce guard."""
expected = self._normalized_zone_control(payload)
loop = asyncio.get_running_loop()
self._pending_zone_controls[zone_id] = (loop.time() + 5.0, expected)
if self.plan:
optimistic_plan = dict(self.plan)
optimistic_plan["zones"] = [dict(zone) for zone in self.plan.get("zones", [])]
self._overlay_pending_zone_controls(optimistic_plan)
self.plan = optimistic_plan
self.async_set_updated_data(dict(self.data or {}))
try:
await self.client.zone_control(zone_id, payload)
except GreeControllerApiError:
self._pending_zone_controls.pop(zone_id, None)
await self.async_request_refresh()
raise
self._pending_zone_controls[zone_id] = (loop.time() + 3.0, expected)
await self.async_request_refresh()
async def async_device_command(self, device_id: str, payload: dict[str, Any]) -> None:
"""Send a physical-unit command while keeping HA state monotonic during settling."""
expected = self._normalized_device_command(payload)
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.7.8",
"version": "0.7.9",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -76,5 +76,4 @@ class GreeControllerZoneTargetNumber(CoordinatorEntity[GreeControllerCoordinator
)
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(value)})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(value)})
@@ -1,4 +1,4 @@
"""Whole-house and climate-group thermostat selectors for GREE Controller."""
"""Whole-house, zone and climate-group thermostat selectors for GREE Controller."""
from __future__ import annotations
@@ -45,12 +45,16 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create whole-house thermostat mode and work-profile selectors."""
"""Create whole-house, zone and climate-group thermostat selectors."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SelectEntity] = [
GreeControllerHouseModeSelect(runtime.coordinator),
GreeControllerHousePresetSelect(runtime.coordinator),
]
for zone in runtime.coordinator.plan.get("zones", []):
zone_id = str(zone.get("zone_id") or "").strip()
if zone_id:
entities.append(GreeControllerZonePresetSelect(runtime.coordinator, zone_id))
for group_id in runtime.coordinator.groups:
entities.append(GreeControllerGroupModeSelect(runtime.coordinator, group_id))
entities.append(GreeControllerGroupPresetSelect(runtime.coordinator, group_id))
@@ -116,6 +120,56 @@ class GreeControllerHousePresetSelect(_GreeControllerHouseSelect):
await self.coordinator.async_request_refresh()
class GreeControllerZonePresetSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Select the temporary work profile for one thermostat zone."""
_attr_has_entity_name = True
_attr_name = "Work profile"
_attr_options = list(HOUSE_PRESET_VALUES)
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-work-profile"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def current_option(self) -> str | None:
value = str(self._zone.get("preset_override") or "auto")
return HOUSE_PRESET_LABELS.get(value)
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
return {"active_profile": self._zone.get("preset")}
async def async_select_option(self, option: str) -> None:
value = HOUSE_PRESET_VALUES.get(option)
if value is None:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": value})
class _GreeControllerGroupSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Base class for climate-group selectors."""
@@ -65,7 +65,8 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
"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),
"effective_enabled_zones": sum(bool(zone.get("effective_enabled", zone.get("enabled"))) for zone in zones),
"demanding_zones": sum(bool(zone.get("demand")) for zone in zones),
"groups": len(groups),
"enabled_groups": sum(bool(group.get("power_enabled")) for group in groups),
"demanding_groups": sum(int(group.get("demanding_zones") or 0) > 0 for group in groups),
@@ -102,6 +103,8 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator],
zone = self._zone
if not zone.get("enabled", False):
return "disabled"
if not zone.get("effective_enabled", True):
return "blocked"
return "requesting" if zone.get("demand", False) else "satisfied"
@property
@@ -122,8 +125,11 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator],
"zone_id": self._zone_id,
"device_id": zone.get("device_id"),
"device_name": zone.get("device_name"),
"enabled": bool(zone.get("enabled", False)),
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"mode": zone.get("mode"),
"preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"current_temperature": zone.get("current_temperature"),
"target_temperature": zone.get("target_temperature"),
"device_setpoint": zone.get("device_setpoint"),
@@ -170,12 +170,10 @@ class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinato
)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": True})
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Optional GREE unit feature exposed only when the controller detected it."""
+10 -1
View File
@@ -654,11 +654,18 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
}
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 };
let house_mode = state.settings.read().await.house_mode.clone();
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
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;
} else {
// Do not force users to wait for the fixed zone interval after selecting a profile,
// changing the target/mode or re-enabling a thermostat. The regulator still owns the
// physical command and therefore keeps all group/master/manual-override safeguards.
state.wake_zone_control();
}
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,
@@ -913,6 +920,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"device_id": zone.device_id,
"device_name": zone.device_name,
"enabled": zone.enabled,
"effective_enabled": zone.effective_enabled,
"mode": zone.mode,
"configured_mode": zone.configured_mode,
"inherit_house_mode": zone.inherit_house_mode,
@@ -938,7 +946,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"house_mode": settings.house_mode,
"zone_count": members.len(),
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
"active_zones": planned_members.iter().filter(|zone| zone.enabled).count(),
"active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(),
"demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(),
"device_count": member_device_ids.len(),
"online_devices": online_devices,
@@ -1124,6 +1132,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
}
let failed = command_all_enabled_devices_power(&state, true, "house_preset").await?;
state.wake_zone_control();
let devices = state.db.list_devices()?;
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
"preset": input.preset,
+53 -7
View File
@@ -47,7 +47,10 @@ pub fn start(state: AppState) {
tracing::error!(error=?err, "automation cycle failed");
}
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
sleep(Duration::from_secs(seconds)).await;
tokio::select! {
_ = sleep(Duration::from_secs(seconds)) => {},
_ = control_state.zone_control_wakeup.notified() => {},
}
}
});
@@ -728,6 +731,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
}
}
if desired_power && should_command_power {
state.wake_zone_control();
}
state.log("info", source, &format!("Updated group {}", group.name), json!({
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
@@ -1023,8 +1029,19 @@ async fn control_zones(state: &AppState) -> Result<()> {
// 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 {
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
// selected profile/target. Keep the intended target visible and report the physical
// unit target separately through device_setpoint. This makes Resume/Profile actions
// deterministic and avoids a standby device target (for example 25 C) masquerading
// as the zone's Sleep/Comfort target.
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
// Keep effective_mode's existing meaning during takeover: it reflects the physical
// unit, while effective_setpoint above remains the thermostat intent.
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;
@@ -1529,6 +1546,18 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
(preset, zone.manual_setpoint.unwrap_or(base_target))
}
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
zone.effective_mode = configured_mode.to_string();
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode };
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
zone.active_preset = preset;
if !zone.device_manual_override {
zone.effective_setpoint = Some(target);
}
}
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
schedules.iter()
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
@@ -1677,20 +1706,23 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
event.label = format!("{}: {}", zone.name, event.label);
house_events.push(event);
}
let effective_enabled = zone.enabled && (!blocked_by_group || zone.device_manual_override);
zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(),
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 || zone.device_manual_override),
enabled: zone.enabled,
effective_enabled,
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() },
preset: resolved_preset,
preset_override: zone.manual_preset.clone(),
current_temperature: zone.current_temperature,
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" {
target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
// A remote/manual takeover may leave a physical standby target persisted in the
// zone runtime snapshot. Never publish that value as the thermostat target.
Some(resolved_target)
} else {
zone.effective_setpoint.or(Some(resolved_target))
@@ -2231,6 +2263,20 @@ mod tests {
assert_eq!(target, 22.5);
}
#[test]
fn runtime_target_refresh_applies_manual_profile_immediately() {
let mut zone = test_zone("device");
zone.inherit_house_mode = false;
zone.mode = "cool".into();
zone.manual_preset = Some("sleep".into());
zone.active_preset = "comfort".into();
zone.effective_setpoint = Some(25.0);
refresh_zone_runtime_target(&mut zone, &[], "cool");
assert_eq!(zone.active_preset, "sleep");
assert_eq!(zone.effective_setpoint, Some(24.5));
assert_eq!(zone.effective_mode, "cool");
}
#[test]
fn legacy_zone_keeps_old_comfort_setpoint() {
let mut zone = test_zone("device");
+2 -1
View File
@@ -18,7 +18,7 @@ use db::Db;
use models::Device;
use protocol::GreeClient;
use state::AppState;
use tokio::{net::TcpListener, signal, sync::{broadcast, RwLock}};
use tokio::{net::TcpListener, signal, sync::{broadcast, Notify, RwLock}};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
@@ -67,6 +67,7 @@ async fn main() -> Result<()> {
outdoor_temperature: Arc::new(RwLock::new(None)),
debug_gree_frames,
initial_device_sync_complete: Arc::new(AtomicBool::new(false)),
zone_control_wakeup: Arc::new(Notify::new()),
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
started: Instant::now(),
};
+6
View File
@@ -708,13 +708,19 @@ pub struct ZoneControlPlan {
pub zone_name: String,
pub device_id: String,
pub device_name: String,
/// Configured per-zone enable switch. Group/master gates are reported separately.
pub enabled: bool,
/// True when the configured zone is not currently blocked by a disabled climate group.
pub effective_enabled: bool,
/// Effective mode currently used by the controller.
pub mode: String,
/// Configured zone mode before house-mode inheritance is resolved.
pub configured_mode: String,
pub inherit_house_mode: bool,
/// Profile resolved from a manual override or the active schedule.
pub preset: String,
/// Explicit per-zone profile override; None means Auto schedule.
pub preset_override: Option<String>,
pub current_temperature: Option<f64>,
pub target_temperature: Option<f64>,
pub device_setpoint: Option<f64>,
+7 -1
View File
@@ -1,7 +1,7 @@
use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant};
use chrono::Utc;
use serde_json::Value;
use tokio::sync::{broadcast, Mutex, OwnedMutexGuard, RwLock};
use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock};
use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient};
#[derive(Clone)]
@@ -17,6 +17,8 @@ pub struct AppState {
/// Thermostat/automation control stays passive until every enabled device has had
/// one startup poll, preventing stale persisted device state from causing restart commands.
pub initial_device_sync_complete: Arc<AtomicBool>,
/// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval.
pub zone_control_wakeup: Arc<Notify>,
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub started: Instant,
}
@@ -30,6 +32,10 @@ impl AppState {
lock.lock_owned().await
}
pub fn wake_zone_control(&self) {
self.zone_control_wakeup.notify_one();
}
pub fn broadcast(&self, event: impl Into<String>, data: Value) {
let _ = self.events.send(ApiEvent {
event: event.into(),
+13 -7
View File
@@ -278,6 +278,11 @@ function automationTriggerLabel(item) {
return tr(key, {temperature: fmtTemp(item.threshold)});
}
function planZoneEffectiveEnabled(zone) {
if (!zone) return undefined;
return zone.effective_enabled ?? zone.enabled ?? false;
}
function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return;
const plan = app.controlPlan;
@@ -288,14 +293,14 @@ function renderControlPlan() {
const allZones = plan.zones || [];
const houseEvents = (plan.next_events || []).slice(0, 3);
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:allZones.filter(zone=>zone.enabled).length, demand:allZones.filter(zone=>zone.enabled && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
const groupCards = (app.groups || []).map(group => {
const memberIds = new Set(group.zone_ids || []);
const members = allZones.filter(zone => memberIds.has(zone.zone_id));
const state = groupState(group);
const powerEnabled = group.power_enabled !== false;
const demand = members.filter(zone => zone.enabled && zone.demand).length;
const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length;
const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode);
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`})))
@@ -315,7 +320,7 @@ function renderControlPlan() {
const groupNames = zoneGroups.get(zone.zone_id) || [];
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off');
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>`;
return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : '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' : (planZoneEffectiveEnabled(zone) && 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);
@@ -383,7 +388,7 @@ function buildZoneSimulation(zone, planZone) {
const half = hysteresis / 2;
let status = 'waiting';
let demand = false;
if (!zone?.enabled || planZone?.enabled === false) status = 'disabled';
if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled';
else if (mode === 'off') status = 'off';
else if (current == null || target == null) status = 'waiting';
else {
@@ -419,7 +424,7 @@ function buildZoneSimulation(zone, planZone) {
? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZone?.enabled === false
const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false
? tr('simulation.reasonDisabled')
: mode === 'off'
? tr('simulation.reasonHouseOff')
@@ -701,6 +706,7 @@ function zoneCard(zone, detailed = true) {
const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature;
const target = Number(zone.effective_setpoint ?? zone.setpoint);
const manual = zone.manual_preset || 'auto';
const displayPreset = zone.manual_preset || zone.active_preset || 'comfort';
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;
@@ -726,7 +732,7 @@ function zoneCard(zone, detailed = true) {
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<div class="zone-config-status">
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b></b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : ''}</strong></div></div>
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(zone.active_preset))}</span><small>${esc(override)}</small></div>
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small></div>
</div>
<div class="zone-config-grid">
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
@@ -741,7 +747,7 @@ function zoneCard(zone, detailed = true) {
}
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '' : ''}</span>${esc(state)}</button></div>
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '' : ''}</span>${esc(state)}</button></div>
${manualUnitPower}
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
<div class="preset-row quick-control-row quick-control-row-4">
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v078-zone-status-unit-power';
const CACHE = 'gree-controller-v079-zone-profile-control';
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')];