This commit is contained in:
Mateusz Gruszczyński
2026-08-27 14:16:56 +02:00
parent 584bdca7c6
commit 7102d05cb3
21 changed files with 305 additions and 65 deletions
+3 -1
View File
@@ -122,6 +122,8 @@ The recommended `combined` strategy keeps the GREE sensor as the primary input a
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group.
From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
@@ -153,4 +155,4 @@ After upgrading the custom integration, restart Home Assistant or reload **Setti
## Command state stability (0.7.6)
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
@@ -265,8 +265,10 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
_attr_max_temp = 30.0
_attr_target_temperature_step = 0.5
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
_attr_preset_modes = ["auto", "comfort", "sleep", "away"]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
@@ -317,15 +319,21 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
return HVACMode.AUTO
return HVACMode.HEAT if str(zone.get("configured_mode", zone.get("mode", "cool"))) == "heat" else HVACMode.COOL
@property
def preset_mode(self) -> str:
return str(self._zone.get("preset_override") or "auto")
@property
def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone
return {
"controller_zone_id": self._zone_id,
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"effective_mode": zone.get("mode"),
"follows_house_mode": bool(zone.get("inherit_house_mode", True)),
"auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode",
"preset": zone.get("preset"),
"active_preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"demand": bool(zone.get("demand", False)),
"device_setpoint": zone.get("device_setpoint"),
"control_source": zone.get("control_source"),
@@ -343,21 +351,22 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
payload = {"enabled": True, "mode": "heat"}
else:
return
await self.coordinator.client.zone_control(self._zone_id, payload)
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, payload)
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(temperature)})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(temperature)})
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode not in self._attr_preset_modes:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
async def async_turn_on(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": True})
async def async_turn_off(self) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
@@ -34,6 +34,9 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
# Protect a just-accepted device command from an overlapping/stale poll.
# GREE units can expose their previous status briefly after acknowledging a write.
self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {}
# Zone switches/climate/profile controls need the same protection. A coordinator refresh
# that started before the POST must not make an accepted zone action visibly bounce back.
self._pending_zone_controls: dict[str, tuple[float, dict[str, Any]]] = {}
async def _async_update_data(self) -> dict[str, dict]:
try:
@@ -44,6 +47,7 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
)
except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err
self._overlay_pending_zone_controls(plan)
self.plan = plan
self.groups = {str(group["id"]): group for group in groups if group.get("id")}
device_map = {str(device["id"]): device for device in devices if device.get("id")}
@@ -77,6 +81,68 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
if device is not None:
device.update(expected)
@staticmethod
def _normalized_zone_control(payload: dict[str, Any]) -> dict[str, Any]:
"""Map zone-control API fields to the Home Assistant control-plan representation."""
expected: dict[str, Any] = {}
if "enabled" in payload:
expected["enabled"] = bool(payload["enabled"])
if "mode" in payload:
mode = str(payload["mode"])
if mode in {"house", "auto"}:
expected["inherit_house_mode"] = True
elif mode in {"cool", "heat"}:
expected["inherit_house_mode"] = False
expected["configured_mode"] = mode
expected["mode"] = mode
if "preset" in payload:
preset = str(payload["preset"])
expected["preset_override"] = None if preset == "auto" else preset
if preset != "auto":
expected["preset"] = preset
if "setpoint" in payload:
value = max(8.0, min(30.0, float(payload["setpoint"])))
expected["target_temperature"] = round(value * 2.0) / 2.0
if payload.get("clear_override"):
expected["preset_override"] = None
return expected
def _overlay_pending_zone_controls(self, plan: dict[str, Any]) -> None:
"""Overlay accepted zone controls onto stale/in-flight control-plan reads."""
now = asyncio.get_running_loop().time()
zones = plan.get("zones", [])
for zone_id, (deadline, expected) in list(self._pending_zone_controls.items()):
if now >= deadline:
self._pending_zone_controls.pop(zone_id, None)
continue
for zone in zones:
if str(zone.get("zone_id")) == zone_id:
zone.update(expected)
break
async def async_zone_control(self, zone_id: str, payload: dict[str, Any]) -> None:
"""Send a thermostat-zone command with a short optimistic anti-bounce guard."""
expected = self._normalized_zone_control(payload)
loop = asyncio.get_running_loop()
self._pending_zone_controls[zone_id] = (loop.time() + 5.0, expected)
if self.plan:
optimistic_plan = dict(self.plan)
optimistic_plan["zones"] = [dict(zone) for zone in self.plan.get("zones", [])]
self._overlay_pending_zone_controls(optimistic_plan)
self.plan = optimistic_plan
self.async_set_updated_data(dict(self.data or {}))
try:
await self.client.zone_control(zone_id, payload)
except GreeControllerApiError:
self._pending_zone_controls.pop(zone_id, None)
await self.async_request_refresh()
raise
self._pending_zone_controls[zone_id] = (loop.time() + 3.0, expected)
await self.async_request_refresh()
async def async_device_command(self, device_id: str, payload: dict[str, Any]) -> None:
"""Send a physical-unit command while keeping HA state monotonic during settling."""
expected = self._normalized_device_command(payload)
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.7.8",
"version": "0.7.9",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -76,5 +76,4 @@ class GreeControllerZoneTargetNumber(CoordinatorEntity[GreeControllerCoordinator
)
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(value)})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(value)})
@@ -1,4 +1,4 @@
"""Whole-house and climate-group thermostat selectors for GREE Controller."""
"""Whole-house, zone and climate-group thermostat selectors for GREE Controller."""
from __future__ import annotations
@@ -45,12 +45,16 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create whole-house thermostat mode and work-profile selectors."""
"""Create whole-house, zone and climate-group thermostat selectors."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SelectEntity] = [
GreeControllerHouseModeSelect(runtime.coordinator),
GreeControllerHousePresetSelect(runtime.coordinator),
]
for zone in runtime.coordinator.plan.get("zones", []):
zone_id = str(zone.get("zone_id") or "").strip()
if zone_id:
entities.append(GreeControllerZonePresetSelect(runtime.coordinator, zone_id))
for group_id in runtime.coordinator.groups:
entities.append(GreeControllerGroupModeSelect(runtime.coordinator, group_id))
entities.append(GreeControllerGroupPresetSelect(runtime.coordinator, group_id))
@@ -116,6 +120,56 @@ class GreeControllerHousePresetSelect(_GreeControllerHouseSelect):
await self.coordinator.async_request_refresh()
class GreeControllerZonePresetSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Select the temporary work profile for one thermostat zone."""
_attr_has_entity_name = True
_attr_name = "Work profile"
_attr_options = list(HOUSE_PRESET_VALUES)
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-work-profile"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def current_option(self) -> str | None:
value = str(self._zone.get("preset_override") or "auto")
return HOUSE_PRESET_LABELS.get(value)
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
return {"active_profile": self._zone.get("preset")}
async def async_select_option(self, option: str) -> None:
value = HOUSE_PRESET_VALUES.get(option)
if value is None:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": value})
class _GreeControllerGroupSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Base class for climate-group selectors."""
@@ -65,7 +65,8 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
"work_profile": plan.get("house_preset"),
"master_power": bool(plan.get("house_power", False)),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones),
"effective_enabled_zones": sum(bool(zone.get("effective_enabled", zone.get("enabled"))) for zone in zones),
"demanding_zones": sum(bool(zone.get("demand")) for zone in zones),
"groups": len(groups),
"enabled_groups": sum(bool(group.get("power_enabled")) for group in groups),
"demanding_groups": sum(int(group.get("demanding_zones") or 0) > 0 for group in groups),
@@ -102,6 +103,8 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator],
zone = self._zone
if not zone.get("enabled", False):
return "disabled"
if not zone.get("effective_enabled", True):
return "blocked"
return "requesting" if zone.get("demand", False) else "satisfied"
@property
@@ -122,8 +125,11 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator],
"zone_id": self._zone_id,
"device_id": zone.get("device_id"),
"device_name": zone.get("device_name"),
"enabled": bool(zone.get("enabled", False)),
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"mode": zone.get("mode"),
"preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"current_temperature": zone.get("current_temperature"),
"target_temperature": zone.get("target_temperature"),
"device_setpoint": zone.get("device_setpoint"),
@@ -170,12 +170,10 @@ class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinato
)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": True})
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Optional GREE unit feature exposed only when the controller detected it."""