v0.9.7
This commit is contained in:
@@ -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