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
+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."""