Files
gree-controller-ha-addon/custom_components/gree_controller/coordinator.py
T

198 lines
9.1 KiB
Python

"""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()