diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index c4808ff..0da2970 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 9d268ed..2250233 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.7.8" +version = "0.7.9" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 2dd364f..8f63b92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index d23219a..6aa540b 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 29e5f9a..45e0f38 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.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**. diff --git a/docs/API.md b/docs/API.md index 3adc18e..2f4df95 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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" diff --git a/home-assistant/README.md b/home-assistant/README.md index 06db60b..b2f6b36 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -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._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. diff --git a/home-assistant/custom_components/gree_controller/climate.py b/home-assistant/custom_components/gree_controller/climate.py index e8d00c8..0276f4f 100644 --- a/home-assistant/custom_components/gree_controller/climate.py +++ b/home-assistant/custom_components/gree_controller/climate.py @@ -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}) diff --git a/home-assistant/custom_components/gree_controller/coordinator.py b/home-assistant/custom_components/gree_controller/coordinator.py index c55eb1d..d1c6985 100644 --- a/home-assistant/custom_components/gree_controller/coordinator.py +++ b/home-assistant/custom_components/gree_controller/coordinator.py @@ -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) diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 0635f1f..cc1bdf0 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.7.8", + "version": "0.7.9", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/home-assistant/custom_components/gree_controller/number.py b/home-assistant/custom_components/gree_controller/number.py index 55427ff..a8afee7 100644 --- a/home-assistant/custom_components/gree_controller/number.py +++ b/home-assistant/custom_components/gree_controller/number.py @@ -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)}) diff --git a/home-assistant/custom_components/gree_controller/select.py b/home-assistant/custom_components/gree_controller/select.py index bf3817a..b3209fb 100644 --- a/home-assistant/custom_components/gree_controller/select.py +++ b/home-assistant/custom_components/gree_controller/select.py @@ -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.""" diff --git a/home-assistant/custom_components/gree_controller/sensor.py b/home-assistant/custom_components/gree_controller/sensor.py index 31aec7d..8ecb7da 100644 --- a/home-assistant/custom_components/gree_controller/sensor.py +++ b/home-assistant/custom_components/gree_controller/sensor.py @@ -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"), diff --git a/home-assistant/custom_components/gree_controller/switch.py b/home-assistant/custom_components/gree_controller/switch.py index 1fb4b74..6dac177 100644 --- a/home-assistant/custom_components/gree_controller/switch.py +++ b/home-assistant/custom_components/gree_controller/switch.py @@ -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.""" diff --git a/src/api.rs b/src/api.rs index 4cb41e8..b067c25 100644 --- a/src/api.rs +++ b/src/api.rs @@ -654,11 +654,18 @@ async fn update_zone_control(State(state): State, Path(id): Path) -> Result) -> Result, Json(input): Json {}, + _ = 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) -> 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 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(), }; diff --git a/src/models.rs b/src/models.rs index 306b478..d7594ae 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, pub current_temperature: Option, pub target_temperature: Option, pub device_setpoint: Option, diff --git a/src/state.rs b/src/state.rs index 9245f12..bc7ba3c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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, + /// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval. + pub zone_control_wakeup: Arc, pub(crate) device_operation_locks: Arc>>>>, 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, data: Value) { let _ = self.events.send(ApiEvent { event: event.into(), diff --git a/web/app.js b/web/app.js index 5ef7bf7..88a4f82 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = `
${esc(tr('plan.house'))}

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

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

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

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

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

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

${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}

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

${esc(zone.zone_name)}

${esc(planState)}
${fmtTemp(zone.current_temperature)}${esc(target)}°C

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

    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; + return `
${esc(scope)}

${esc(zone.zone_name)}

${esc(planState)}
${fmtTemp(zone.current_temperature)}${esc(target)}°C

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

    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
`; }).join(''); const rules = (plan.rules || []).filter(rule => rule.enabled); @@ -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) {
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

${esc(state)}
${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
-
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(zone.active_preset))}${esc(override)}
+
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}
${esc(tr('zones.modePolicy'))}${esc(policy)}
@@ -741,7 +747,7 @@ function zoneCard(zone, detailed = true) { } return `
-

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}

+

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}

${manualUnitPower}
${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target)?target.toFixed(1):'--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
diff --git a/web/sw.js b/web/sw.js index 99fff4b..38de26b 100644 --- a/web/sw.js +++ b/web/sw.js @@ -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')];