"""Data coordinator for the GREE Controller integration.""" from __future__ import annotations import asyncio from datetime import timedelta import logging from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import GreeControllerApiError, GreeControllerClient from .const import DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN _LOGGER = logging.getLogger(__name__) class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Poll the standalone controller and cache device states.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: GreeControllerClient) -> None: super().__init__( hass, logger=_LOGGER, name=DOMAIN, config_entry=entry, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS), ) self.client = client self.plan: dict[str, Any] = {} self.groups: dict[str, dict[str, Any]] = {} # 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: snapshot = await self.client.snapshot() devices = snapshot["devices"] plan = snapshot["control_plan"] groups = snapshot["groups"] 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")} self._overlay_pending_device_commands(device_map) return device_map @staticmethod def _normalized_device_command(payload: dict[str, Any]) -> dict[str, Any]: """Normalize writable fields exactly as the standalone controller does.""" allowed = { "power", "mode", "target_temperature", "fan_speed", "swing_vertical", "swing_horizontal", "quiet", "turbo", "light", "air", "xfan", "health", "sleep", } expected = {key: value for key, value in payload.items() if key in allowed} if "target_temperature" in expected: value = max(8.0, min(30.0, float(expected["target_temperature"]))) expected["target_temperature"] = float(int(value + 0.5)) if "fan_speed" in expected: expected["fan_speed"] = min(5, max(0, int(expected["fan_speed"]))) if "swing_vertical" in expected: expected["swing_vertical"] = min(11, max(0, int(expected["swing_vertical"]))) if "swing_horizontal" in expected: expected["swing_horizontal"] = min(6, max(0, int(expected["swing_horizontal"]))) return expected def _overlay_pending_device_commands(self, devices: dict[str, dict[str, Any]]) -> None: """Do not let a stale post-command poll make HA controls bounce backwards.""" now = asyncio.get_running_loop().time() for device_id, (deadline, expected) in list(self._pending_device_commands.items()): if now >= deadline: self._pending_device_commands.pop(device_id, None) continue device = devices.get(device_id) 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 "power" in payload: expected["local_thermostat_power"] = bool(payload["power"]) 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: zone = await self.client.zone_control(zone_id, payload) except GreeControllerApiError: self._pending_zone_controls.pop(zone_id, None) await self.async_request_refresh() raise # The backend owns hand-back timing. Keep the exact accepted deadline returned # by the zone API in the optimistic overlay so an overlapping/stale plan poll # cannot temporarily resurrect the previous countdown in Home Assistant. for key in ( "local_thermostat_power", "local_thermostat_resume_at", "device_manual_override", "device_manual_override_until", ): if key in zone: expected[key] = zone[key] 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) loop = asyncio.get_running_loop() # Register intent before network I/O. A coordinator refresh that was already in flight # when the user clicked must not be allowed to publish the pre-command state. self._pending_device_commands[device_id] = (loop.time() + 5.0, expected) current = dict(self.data or {}) if device_id in current: optimistic = dict(current[device_id]) optimistic.update(expected) current[device_id] = optimistic self.async_set_updated_data(current) try: device = await self.client.command(device_id, payload) except GreeControllerApiError: # Never mask an actual rejected/failed command. Drop the optimistic guard and # immediately restore the latest factual controller state. self._pending_device_commands.pop(device_id, None) await self.async_request_refresh() raise # Keep the guard briefly after a successful ACK so delayed firmware status and an # overlapping coordinator refresh cannot roll the entity backwards. self._pending_device_commands[device_id] = (loop.time() + 3.0, expected) current = dict(self.data or {}) optimistic = dict(device) optimistic.update(expected) current[device_id] = optimistic self.async_set_updated_data(current) # Refresh plan/group metadata immediately as before. Any stale device snapshot in # this refresh is overlaid by the short pending-command guard above. await self.async_request_refresh()