This commit is contained in:
Mateusz Gruszczyński
2026-08-25 19:44:12 +02:00
parent 62d071c972
commit 0d65a6a946
15 changed files with 464 additions and 38 deletions
+23 -1
View File
@@ -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).
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.7.2"
version = "0.7.3"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+14 -14
View File
@@ -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
+16 -3
View File
@@ -4,7 +4,7 @@
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
Current version: **0.7.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.
+13 -1
View File
@@ -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" \
+16 -2
View File
@@ -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.
@@ -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(
@@ -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")}
@@ -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",
@@ -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()
@@ -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", []),
}
@@ -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."""
+123
View File
@@ -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<AppState>, Path(id): Path<Stri
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
}
fn home_assistant_group_mode(zones: &[&Zone]) -> 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<AppState>) -> Result<Json<Vec<Value>>, 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::<Vec<_>>();
let planned_members = plan.zones.iter()
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id))
.collect::<Vec<_>>();
let zone_names = members.iter().map(|zone| zone.name.clone()).collect::<Vec<_>>();
let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::<std::collections::HashSet<_>>();
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::<Vec<_>>();
let current_temperature = if current_temperatures.is_empty() {
None
} else {
Some(current_temperatures.iter().sum::<f64>() / 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::<Vec<_>>();
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<AppState>,
Path(id): Path<String>,
Json(patch): Json<GroupControlPatch>,
) -> Result<Json<Value>, AppError> {
Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?))
}
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
+1 -1
View File
@@ -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')];