v0.9.7
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user