From 0d65a6a946ea9e8c76c7ab9ede210d191bc075b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 25 Aug 2026 19:44:12 +0200 Subject: [PATCH] v0.7.2 --- BUILD_REPORT.md | 24 +++- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 28 ++-- README.md | 19 ++- docs/API.md | 14 +- home-assistant/README.md | 18 ++- .../custom_components/gree_controller/api.py | 18 +++ .../gree_controller/coordinator.py | 5 +- .../gree_controller/manifest.json | 2 +- .../gree_controller/select.py | 109 +++++++++++++++- .../gree_controller/sensor.py | 74 ++++++++++- .../gree_controller/switch.py | 62 ++++++++- src/api.rs | 123 ++++++++++++++++++ web/sw.js | 2 +- 15 files changed, 464 insertions(+), 38 deletions(-) diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 7fa8aff..ec09937 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,4 +1,4 @@ -# GREE Controller v0.7.2 - build and validation report +# GREE Controller v0.7.3 - build and validation report ## Scope @@ -260,3 +260,25 @@ Validation for the 0.7.1 package: - Restored `tokio-util = 0.7.19`, which satisfies `tower-http 0.6.11` (`^0.7`) and is present in the crates.io index used by the target LXC. - No application source, database schema, scheduler, automation, device-control or Web UI logic changed in this hotfix. + + +## 0.7.3 Home Assistant climate-group support + +- Added restricted HA group discovery and group-control endpoints. +- Added one Home Assistant device per configured climate group. +- Added group Power switch, Thermostat mode select, Work profile select and Control plan sensor. +- Group reporting derives common mode/profile from member-zone configuration and reports mixed states without coercing them. +- Group plan attributes include members, online unit count, average current temperature, demand count and upcoming zone schedule events. +- Existing group-control engine remains the single command path, including global-master-power and overlapping-disabled-group protections. + +### 0.7.3 validation + +- `python3 -m py_compile home-assistant/custom_components/gree_controller/*.py` passed. +- Home Assistant manifest and PL/EN translation JSON parsed successfully. +- `Cargo.toml` parses successfully and both Cargo package metadata and HA manifest report `0.7.3`. +- `Cargo.lock` keeps the packaging hotfix at `tokio-util = 0.7.19`. +- Static assertions confirm the restricted HA router exposes group discovery/control, the API client polls groups, and select/switch/sensor group entities are registered. +- `node --check web/app.js` and `node --check web/sw.js` passed. +- `bash -n scripts/*.sh` passed. +- HTML parsing and CSS brace-balance checks passed. +- The validation environment does not provide `cargo`/`rustc`, so the Rust test/build step must run on the target host/LXC (the normal `scripts/update.sh` performs `cargo test --all-targets` before installation). diff --git a/Cargo.lock b/Cargo.lock index fb2e7b7..c16fd2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.7.2" +version = "0.7.3" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index c5d72db..320ed8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.7.2" +version = "0.7.3" 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 26ba070..b429e88 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,33 +1,33 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -0bd13fab94fb9c1aeabc4e1f1c0f08a583b562ac1424daf381d325ac423a4f1a ./BUILD_REPORT.md -c52a8cbea1144ea62698b2e2c21ff2ba4dc7a3201a65d17d4fb20ebe68cfe4ab ./Cargo.lock -fac8e8ca6bad6375be66e3da712deb943fa3bb44c78e84cfc99aef2ee3ea21c9 ./Cargo.toml +f6c797abd321abcf1ad9ab454e2a2949d588ed1465b12fd3383530c71bda9070 ./BUILD_REPORT.md +cff434b43ec947285275a72653bde756b57987d7191ec4093f9218bd16653936 ./Cargo.lock +f3e0db0deb28cda7669b9344436d883529143f64e8dc89624986fe14b9fa502d ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -40257d2ace2752e53c6119ef5d09e4b98433cb1b86b5a3d0a69e62cc888a8a48 ./README.md +b526a6b28e1b8cb68be8bd92627a8588c0964de109f99b83c173ecec151ae543 ./README.md f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs -cd45c288c3e002925656f7e06656034fd07013774f4ba0a00ba2bfb1fe60195a ./docs/API.md +b396c7016569a5817ed740abadd0e5685a8e7d50300bfce607cc3c15009ba78d ./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 -1192c5442cb0439723294bbed8b4610728c51bca23e1d8c0ef030270d1c697ba ./home-assistant/README.md +a7889fa5880da4c6d6d1b1f63a139051185cfb04769a735ee72c9fd536f1cb62 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py -001215412d77abd393f34c414ff6d0ebbaf419f140d76037b7f114a7955df6c3 ./home-assistant/custom_components/gree_controller/api.py +6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py 62547d17114dd2ae47881b0789279f2d595285b630af33830244c31074801961 ./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 -8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py +2eb70cafaeccaf2e62a8de650fed90bac5c8b12be70cf33f80a2f7d8a274a606 ./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 -a8cd36e29f59a4df7bc9c37098539a143db18f383c1ae5e657129e14452cd6b0 ./home-assistant/custom_components/gree_controller/manifest.json +61cb322659bf9cdc2490b20ad3c5aab92def81660d6a01dbcd978ddebc162813 ./home-assistant/custom_components/gree_controller/manifest.json d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py -459233ccefa7827fc1c6887146d8a69e01149a497635fd5e1802c220f418eb28 ./home-assistant/custom_components/gree_controller/select.py -ec35191e27a018a72cb341914e2abd7fecd25f6b641ea25bebb9e809dd8ecaff ./home-assistant/custom_components/gree_controller/sensor.py -915119bcf400e0da6df777b0a2c13fe33b96daca91f5d8c145acf4831b62098d ./home-assistant/custom_components/gree_controller/switch.py +a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./home-assistant/custom_components/gree_controller/select.py +047f3db03f0db54a40d63092370e5861510d611edfb0a0659e4c25b949d47854 ./home-assistant/custom_components/gree_controller/sensor.py +31b8462e00644ce12aa37b849341a95c7498909961d6c35be3d43867904c6c81 ./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,7 +46,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -13ba625f9b0db3caed3a8c09f2a18d7485f2574e23daa5375a407ab63ece169e ./src/api.rs +7b4309a30eed56af51c175db00e12649921edc6f515df477c68bc09c750929c9 ./src/api.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs 0f818e27983476ad41bf8f647749bc5a7dc7924731bb436ec996b0c8e595f93d ./src/engine.rs @@ -67,5 +67,5 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon. 57949bed2229ceb180ea681b0126b56f6e75dc325d7a89d384f823282736bdc7 ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest e4ed0381250352f752e3b36133a89947db1adb08dec9cae7d2467684dbd1a807 ./web/styles.css -0e7a083b418c651fb37d121b978fd974206dfc804aaab868b1f2e4294924be3f ./web/sw.js +4a60c17e07f1c17d79d5316cb4a34418087f7428cc0326cf536c043261e926a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 8e1ceb4..0d1bd2f 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.2**. +Current version: **0.7.3**. ## Highlights @@ -37,7 +37,7 @@ Current version: **0.7.2**. - optional Bearer-token authentication, - simulator mode for development without physical hardware, - Debian/Ubuntu LXC systemd installer, -- Home Assistant custom integration that proxies device `climate` commands, exposes full zone `climate` entities with selectable HVAC mode, zone plan sensors, writable target-temperature numbers and enable switches, +- Home Assistant custom integration that proxies device `climate` commands, exposes full zone thermostat entities and publishes every climate group as a separate HA device with power, mode, profile and control-plan entities, - migration mapping generator for retaining existing HA entity IDs such as `climate.klima_salon`. See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`docs/LXC.md`](docs/LXC.md) for the LXC deployment/update workflow. @@ -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.2.zip +unzip gree-controller-v0.7.3.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -324,6 +324,8 @@ POST /api/integrations/home-assistant/test GET /api/integrations/home-assistant/devices POST /api/integrations/home-assistant/devices/{id}/command GET /api/integrations/home-assistant/control-plan +GET /api/integrations/home-assistant/groups +POST /api/integrations/home-assistant/groups/{id}/control POST /api/integrations/home-assistant/house/control POST /api/integrations/home-assistant/house/preset POST /api/integrations/home-assistant/house/power @@ -433,3 +435,14 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C - The **Control** button opens the Dashboard, expands **Quick thermostats** and scrolls to the matching zone. - The zone form no longer exposes the legacy/base `setpoint` as a live-looking control; profile temperatures remain configurable. - Live zone temperature, preset, mode and enable/disable controls remain available in **Dashboard -> Quick thermostats**. + +## 0.7.3 Home Assistant climate groups + +- Added climate groups to the restricted Home Assistant API. +- Each configured group is exposed as its own Home Assistant device with **Power**, **Thermostat mode**, **Work profile** and **Control plan** entities. +- Group mode options are **Global**, **Cooling** and **Heating**; Global means the member zones inherit the controller-wide Heating/Cooling selection. +- Group profile options are **Auto schedule**, **Comfort**, **Sleep** and **Away**. +- The group control-plan sensor reports member zones, online unit count, current average temperature, demand count and upcoming member schedule events. +- Mixed member modes/profiles are reported without inventing a false common value; choosing a group option normalizes all member zones through the existing group-control engine. +- Group commands use the restricted Home Assistant access token and the dedicated `/api/integrations/home-assistant/groups/*` endpoints. +- After adding/removing/renaming groups, reload the GREE Controller integration in Home Assistant so its entity/device registry is rebuilt. diff --git a/docs/API.md b/docs/API.md index dd342ec..52536e3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,6 +26,8 @@ The Home Assistant custom integration uses a restricted API surface: GET /api/integrations/home-assistant/devices POST /api/integrations/home-assistant/devices/{id}/command GET /api/integrations/home-assistant/control-plan +GET /api/integrations/home-assistant/groups +POST /api/integrations/home-assistant/groups/{id}/control POST /api/integrations/home-assistant/house/control POST /api/integrations/home-assistant/house/preset POST /api/integrations/home-assistant/house/power @@ -193,7 +195,17 @@ This setting is opt-in and applies only to the controller's outbound Home Assist curl "$BASE/api/control-plan" -H "$AUTH" ``` -The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also change house mode, house preset and whole-house master power through the dedicated `/api/integrations/home-assistant/house/*` endpoints. HA may change a zone target/preset/mode/enabled state through: +The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also read derived group state from `GET /api/integrations/home-assistant/groups`, control a group through `POST /api/integrations/home-assistant/groups/{id}/control`, change house mode/preset/master power through `/api/integrations/home-assistant/house/*`, and change a zone target/preset/mode/enabled state through: + +The group list reports the configured group power state, common mode/profile (or `mixed`), member zones, online device count, average room temperature, demand count and upcoming member schedule events. Group control accepts the same semantics as the Web UI: + +```bash +curl -X POST "$BASE/api/integrations/home-assistant/groups/GROUP_ID/control" \ + -H 'Authorization: Bearer HA_TOKEN' -H 'Content-Type: application/json' \ + -d '{"mode":"house","preset":"comfort"}' +``` + +Valid group modes are `house` (inherit global), `cool` and `heat`; valid profiles are `auto`, `comfort`, `sleep` and `away`. `power` can be sent independently as a boolean. ```bash curl -X POST "$BASE/api/integrations/home-assistant/zones/ZONE_ID/control" \ diff --git a/home-assistant/README.md b/home-assistant/README.md index 69d2c4d..6787855 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -6,7 +6,7 @@ The project includes a custom Home Assistant integration under: home-assistant/custom_components/gree_controller/ ``` -It creates HA `climate` entities but sends every command to the standalone Rust controller. Home Assistant therefore becomes a client, while UDP/AES GREE communication remains outside HA. +It creates HA entities for physical units, thermostat zones, whole-house controls and climate groups, while sending every command to the standalone Rust controller. Home Assistant therefore remains a client and UDP/AES GREE communication stays outside HA. The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing. @@ -34,7 +34,7 @@ Restart Home Assistant, then open **Settings -> Devices & services -> Add integr - controller URL, for example `http://192.168.1.20:8787`, - the generated controller access token. -The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices/control plans and sending climate, zone target or zone enable/disable commands. +The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices/control plans/groups and sending device, zone, whole-house or climate-group control commands. ## Preserve an existing entity ID @@ -136,3 +136,17 @@ The integration also exposes three controller-level entities on the **GREE Contr - **All air conditioners** (`switch`) — separate master power. Turning it off powers every enabled unit down and prevents zones/controller automations from restarting them. Turning it on powers all enabled units on again without changing the selected thermostat mode or profile. These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints. + + +## Climate groups (0.7.3) + +Every group configured in **GREE Controller -> Groups** is published as a separate Home Assistant device. A group does not receive a fake common target temperature because its member zones can legitimately use different profile temperatures. Instead, the group device exposes the controls that exactly match the controller model: + +- **Power** (`switch`) — enables/disables only this group. +- **Thermostat mode** (`select`) — **Global**, Cooling or Heating. **Global** means all member zones inherit the controller-wide Heating/Cooling selection. +- **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away. +- **Control plan** (`sensor`) — group status plus member zone IDs/names, average current temperature, enabled/demanding zone counts, online unit count and upcoming member schedule events. + +If member zones have been changed individually and no longer share one mode or profile, the matching select has no current common value instead of reporting a misleading state. Selecting a group mode/profile applies it to every member through the existing controller group logic. + +After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created. diff --git a/home-assistant/custom_components/gree_controller/api.py b/home-assistant/custom_components/gree_controller/api.py index 592c423..77e85e9 100644 --- a/home-assistant/custom_components/gree_controller/api.py +++ b/home-assistant/custom_components/gree_controller/api.py @@ -82,6 +82,24 @@ class GreeControllerClient: raise GreeControllerApiError("Controller returned an invalid control plan payload") return data + async def groups(self) -> list[dict[str, Any]]: + """Return climate groups exposed to Home Assistant.""" + data = await self._request("GET", "/api/integrations/home-assistant/groups") + if not isinstance(data, list): + raise GreeControllerApiError("Controller returned an invalid groups payload") + return data + + async def group_control(self, group_id: str, payload: dict[str, Any]) -> dict[str, Any]: + """Change one controller climate group.""" + data = await self._request( + "POST", + f"/api/integrations/home-assistant/groups/{group_id}/control", + json=payload, + ) + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid group control payload") + return data + async def house_control(self, mode: str) -> dict[str, Any]: """Change the whole-house thermostat mode.""" data = await self._request( diff --git a/home-assistant/custom_components/gree_controller/coordinator.py b/home-assistant/custom_components/gree_controller/coordinator.py index 27ec33e..0232f21 100644 --- a/home-assistant/custom_components/gree_controller/coordinator.py +++ b/home-assistant/custom_components/gree_controller/coordinator.py @@ -30,14 +30,17 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]): ) self.client = client self.plan: dict[str, Any] = {} + self.groups: dict[str, dict[str, Any]] = {} async def _async_update_data(self) -> dict[str, dict]: try: - devices, plan = await asyncio.gather( + devices, plan, groups = await asyncio.gather( self.client.devices(), self.client.control_plan(), + self.client.groups(), ) except GreeControllerApiError as err: raise UpdateFailed(str(err)) from err self.plan = plan + self.groups = {str(group["id"]): group for group in groups if group.get("id")} return {str(device["id"]): device for device in devices if device.get("id")} diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index f3caf23..1ce82d7 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.2", + "version": "0.7.3", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/home-assistant/custom_components/gree_controller/select.py b/home-assistant/custom_components/gree_controller/select.py index afe807d..bf3817a 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 thermostat controls for GREE Controller.""" +"""Whole-house and climate-group thermostat selectors for GREE Controller.""" from __future__ import annotations @@ -30,6 +30,15 @@ HOUSE_PRESET_LABELS = { } HOUSE_PRESET_VALUES = {label: value for value, label in HOUSE_PRESET_LABELS.items()} +GROUP_MODE_LABELS = { + "house": "Global", + "cool": "Cooling", + "heat": "Heating", +} +GROUP_MODE_VALUES = {label: value for value, label in GROUP_MODE_LABELS.items()} +GROUP_PRESET_LABELS = HOUSE_PRESET_LABELS +GROUP_PRESET_VALUES = HOUSE_PRESET_VALUES + async def async_setup_entry( hass: HomeAssistant, @@ -38,12 +47,14 @@ async def async_setup_entry( ) -> None: """Create whole-house thermostat mode and work-profile selectors.""" runtime: GreeControllerRuntimeData = entry.runtime_data - async_add_entities( - [ - GreeControllerHouseModeSelect(runtime.coordinator), - GreeControllerHousePresetSelect(runtime.coordinator), - ] - ) + entities: list[SelectEntity] = [ + GreeControllerHouseModeSelect(runtime.coordinator), + GreeControllerHousePresetSelect(runtime.coordinator), + ] + for group_id in runtime.coordinator.groups: + entities.append(GreeControllerGroupModeSelect(runtime.coordinator, group_id)) + entities.append(GreeControllerGroupPresetSelect(runtime.coordinator, group_id)) + async_add_entities(entities) class _GreeControllerHouseSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity): @@ -103,3 +114,87 @@ class GreeControllerHousePresetSelect(_GreeControllerHouseSelect): return await self.coordinator.client.house_preset(value) await self.coordinator.async_request_refresh() + + +class _GreeControllerGroupSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity): + """Base class for climate-group selectors.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator) + self._group_id = group_id + + @property + def _group(self) -> dict[str, Any]: + return self.coordinator.groups.get(self._group_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._group) + + @property + def device_info(self) -> DeviceInfo: + group = self._group + return DeviceInfo( + identifiers={(DOMAIN, f"group:{self._group_id}")}, + name=str(group.get("name") or self._group_id), + manufacturer="GREE Controller", + model="Climate group", + via_device=(DOMAIN, "controller"), + ) + + +class GreeControllerGroupModeSelect(_GreeControllerGroupSelect): + """Select the thermostat mode policy for one climate group.""" + + _attr_name = "Thermostat mode" + _attr_options = list(GROUP_MODE_VALUES) + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator, group_id) + self._attr_unique_id = f"{group_id}-group-thermostat-mode" + + @property + def current_option(self) -> str | None: + return GROUP_MODE_LABELS.get(str(self._group.get("mode") or "")) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return { + "mixed_group_mode": self._group.get("mode") == "mixed", + "global_house_mode": self._group.get("house_mode"), + } + + async def async_select_option(self, option: str) -> None: + value = GROUP_MODE_VALUES.get(option) + if value is None: + return + await self.coordinator.client.group_control(self._group_id, {"mode": value}) + await self.coordinator.async_request_refresh() + + +class GreeControllerGroupPresetSelect(_GreeControllerGroupSelect): + """Select the work profile for one climate group.""" + + _attr_name = "Work profile" + _attr_options = list(GROUP_PRESET_VALUES) + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator, group_id) + self._attr_unique_id = f"{group_id}-group-work-profile" + + @property + def current_option(self) -> str | None: + return GROUP_PRESET_LABELS.get(str(self._group.get("preset") or "")) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return {"mixed_group_profiles": self._group.get("preset") == "mixed"} + + async def async_select_option(self, option: str) -> None: + value = GROUP_PRESET_VALUES.get(option) + if value is None: + return + await self.coordinator.client.group_control(self._group_id, {"preset": value}) + await self.coordinator.async_request_refresh() diff --git a/home-assistant/custom_components/gree_controller/sensor.py b/home-assistant/custom_components/gree_controller/sensor.py index 998e5fc..31aec7d 100644 --- a/home-assistant/custom_components/gree_controller/sensor.py +++ b/home-assistant/custom_components/gree_controller/sensor.py @@ -1,4 +1,4 @@ -"""Automation-plan sensors exposed by GREE Controller.""" +"""House, zone and climate-group plan sensors exposed by GREE Controller.""" from __future__ import annotations @@ -27,6 +27,8 @@ async def async_setup_entry( for zone in runtime.coordinator.plan.get("zones", []): if zone.get("zone_id"): entities.append(GreeControllerZonePlanSensor(runtime.coordinator, str(zone["zone_id"]))) + for group_id in runtime.coordinator.groups: + entities.append(GreeControllerGroupPlanSensor(runtime.coordinator, group_id)) async_add_entities(entities) @@ -55,6 +57,7 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator] def extra_state_attributes(self) -> dict[str, Any]: plan = self.coordinator.plan zones = plan.get("zones", []) + groups = list(self.coordinator.groups.values()) return { "generated_at": plan.get("generated_at"), "outdoor_temperature": plan.get("outdoor_temperature"), @@ -63,6 +66,9 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator] "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), + "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), "next_events": plan.get("next_events", []), "rules": plan.get("rules", []), } @@ -126,3 +132,69 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator], "current_schedule": zone.get("current_schedule_name"), "next_events": zone.get("next_events", []), } + + +class GreeControllerGroupPlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity): + """Readable status and automation plan for one climate group.""" + + _attr_has_entity_name = True + _attr_name = "Control plan" + _unrecorded_attributes = frozenset({"next_events", "zone_ids", "zone_names", "members"}) + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator) + self._group_id = group_id + self._attr_unique_id = f"{group_id}-group-control-plan" + + @property + def _group(self) -> dict[str, Any]: + return self.coordinator.groups.get(self._group_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._group) + + @property + def native_value(self) -> str: + group = self._group + if not group.get("power_enabled", False): + return "off" + if not group.get("effective_power", False): + return "master_off" + if group.get("mode") == "house" and group.get("house_mode") == "off": + return "paused" + return "requesting" if int(group.get("demanding_zones") or 0) > 0 else "satisfied" + + @property + def device_info(self) -> DeviceInfo: + group = self._group + return DeviceInfo( + identifiers={(DOMAIN, f"group:{self._group_id}")}, + name=str(group.get("name") or self._group_id), + manufacturer="GREE Controller", + model="Climate group", + via_device=(DOMAIN, "controller"), + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + group = self._group + return { + "group_id": self._group_id, + "power_enabled": bool(group.get("power_enabled", False)), + "effective_power": bool(group.get("effective_power", False)), + "mode": group.get("mode"), + "global_house_mode": group.get("house_mode"), + "work_profile": group.get("preset"), + "zone_count": group.get("zone_count"), + "enabled_zones": group.get("enabled_zones"), + "active_zones": group.get("active_zones"), + "demanding_zones": group.get("demanding_zones"), + "device_count": group.get("device_count"), + "online_devices": group.get("online_devices"), + "current_temperature": group.get("current_temperature"), + "zone_ids": group.get("zone_ids", []), + "zone_names": group.get("zone_names", []), + "members": group.get("members", []), + "next_events": group.get("next_events", []), + } diff --git a/home-assistant/custom_components/gree_controller/switch.py b/home-assistant/custom_components/gree_controller/switch.py index bd84311..10cb740 100644 --- a/home-assistant/custom_components/gree_controller/switch.py +++ b/home-assistant/custom_components/gree_controller/switch.py @@ -1,4 +1,4 @@ -"""Writable zone enabled switches for GREE Controller.""" +"""Writable house, group, zone and device switches for GREE Controller.""" from __future__ import annotations @@ -37,9 +37,13 @@ async def async_setup_entry( entities: list[SwitchEntity] = [ GreeControllerHousePowerSwitch(runtime.coordinator), *[ - GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"])) - for zone in runtime.coordinator.plan.get("zones", []) - if zone.get("zone_id") + GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"])) + for zone in runtime.coordinator.plan.get("zones", []) + if zone.get("zone_id") + ], + *[ + GreeControllerGroupPowerSwitch(runtime.coordinator, group_id) + for group_id in runtime.coordinator.groups ], ] for device_id, device in runtime.coordinator.data.items(): @@ -78,6 +82,56 @@ class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator await self.coordinator.async_request_refresh() +class GreeControllerGroupPowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity): + """Enable or disable one controller climate group.""" + + _attr_has_entity_name = True + _attr_name = "Power" + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator) + self._group_id = group_id + self._attr_unique_id = f"{group_id}-group-power" + + @property + def _group(self) -> dict[str, Any]: + return self.coordinator.groups.get(self._group_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._group) + + @property + def is_on(self) -> bool: + return bool(self._group.get("power_enabled", False)) + + @property + def device_info(self) -> DeviceInfo: + group = self._group + return DeviceInfo( + identifiers={(DOMAIN, f"group:{self._group_id}")}, + name=str(group.get("name") or self._group_id), + manufacturer="GREE Controller", + model="Climate group", + via_device=(DOMAIN, "controller"), + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return { + "effective_power": bool(self._group.get("effective_power", False)), + "zone_count": self._group.get("zone_count"), + } + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.coordinator.client.group_control(self._group_id, {"power": True}) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.client.group_control(self._group_id, {"power": False}) + await self.coordinator.async_request_refresh() + + class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity): """Enable or disable a controller thermostat zone.""" diff --git a/src/api.rs b/src/api.rs index b7477f2..fb96d6c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -80,6 +80,8 @@ pub fn router(state: AppState) -> Router { .route("/api/integrations/home-assistant/devices", get(list_devices)) .route("/api/integrations/home-assistant/devices/:id/command", post(command_device)) .route("/api/integrations/home-assistant/control-plan", get(control_plan)) + .route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups)) + .route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control)) .route("/api/integrations/home-assistant/house/control", post(update_house_control)) .route("/api/integrations/home-assistant/house/preset", post(update_house_preset)) .route("/api/integrations/home-assistant/house/power", post(update_house_power)) @@ -737,6 +739,127 @@ async fn update_group_control(State(state): State, Path(id): Path String { + let mut value: Option<&str> = None; + for zone in zones { + let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() }; + if !matches!(current, "house" | "cool" | "heat") { + return "mixed".into(); + } + if let Some(previous) = value { + if previous != current { return "mixed".into(); } + } else { + value = Some(current); + } + } + value.unwrap_or("mixed").to_string() +} + +fn home_assistant_group_preset(zones: &[&Zone]) -> String { + let mut value: Option<&str> = None; + for zone in zones { + let current = zone.manual_preset.as_deref().unwrap_or("auto"); + if !matches!(current, "auto" | "comfort" | "sleep" | "away") { + return "mixed".into(); + } + if let Some(previous) = value { + if previous != current { return "mixed".into(); } + } else { + value = Some(current); + } + } + value.unwrap_or("mixed").to_string() +} + +async fn list_home_assistant_groups(State(state): State) -> Result>, AppError> { + let groups = state.db.list_groups()?; + let zones = state.db.list_zones()?; + let devices = state.db.list_devices()?; + let plan = engine::build_control_plan(&state).await?; + let settings = state.settings.read().await.clone(); + let mut output = Vec::with_capacity(groups.len()); + + for group in groups { + let members = zones.iter() + .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)) + .collect::>(); + let planned_members = plan.zones.iter() + .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id)) + .collect::>(); + + let zone_names = members.iter().map(|zone| zone.name.clone()).collect::>(); + let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::>(); + let online_devices = devices.iter() + .filter(|device| member_device_ids.contains(device.id.as_str()) && device.online) + .count(); + let current_temperatures = planned_members.iter() + .filter_map(|zone| zone.current_temperature) + .collect::>(); + let current_temperature = if current_temperatures.is_empty() { + None + } else { + Some(current_temperatures.iter().sum::() / current_temperatures.len() as f64) + }; + let mut next_events = Vec::new(); + for zone in &planned_members { + for event in &zone.next_events { + let mut event = event.clone(); + event.label = format!("{}: {}", zone.zone_name, event.label); + next_events.push(event); + } + } + next_events.sort_by_key(|event| event.at); + next_events.truncate(8); + let member_states = planned_members.iter().map(|zone| json!({ + "zone_id": zone.zone_id, + "zone_name": zone.zone_name, + "device_id": zone.device_id, + "device_name": zone.device_name, + "enabled": zone.enabled, + "mode": zone.mode, + "configured_mode": zone.configured_mode, + "inherit_house_mode": zone.inherit_house_mode, + "preset": zone.preset, + "current_temperature": zone.current_temperature, + "target_temperature": zone.target_temperature, + "demand": zone.demand, + "control_source": zone.control_source, + "current_schedule": zone.current_schedule_name, + })).collect::>(); + + output.push(json!({ + "id": group.id, + "name": group.name, + "zone_ids": group.zone_ids, + "zone_names": zone_names, + "power_enabled": group.power_enabled, + "effective_power": settings.house_power_enabled && group.power_enabled, + "mode": home_assistant_group_mode(&members), + "preset": home_assistant_group_preset(&members), + "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(), + "demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(), + "device_count": member_device_ids.len(), + "online_devices": online_devices, + "current_temperature": current_temperature, + "members": member_states, + "next_events": next_events, + })); + } + + Ok(Json(output)) +} + +async fn update_home_assistant_group_control( + State(state): State, + Path(id): Path, + Json(patch): Json, +) -> Result, AppError> { + Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?)) +} + #[derive(Debug, Deserialize)] struct HouseControlPatch { mode: String } diff --git a/web/sw.js b/web/sw.js index f1a7569..a263749 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'gree-controller-v072-zone-config'; +const CACHE = 'gree-controller-v073-ha-groups'; 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')];