diff --git a/custom_components/gree_controller/__init__.py b/custom_components/gree_controller/__init__.py new file mode 100644 index 0000000..589aa15 --- /dev/null +++ b/custom_components/gree_controller/__init__.py @@ -0,0 +1,51 @@ +"""Home Assistant bridge for the standalone GREE Controller service.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import GreeControllerApiError, GreeControllerClient +from .const import CONF_TOKEN, CONF_URL, PLATFORMS +from .coordinator import GreeControllerCoordinator +from .entity_map import async_load_entity_map + + +@dataclass +class GreeControllerRuntimeData: + """Runtime objects kept on the config entry.""" + + client: GreeControllerClient + coordinator: GreeControllerCoordinator + entity_map: dict[str, str] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up GREE Controller from a config entry.""" + client = GreeControllerClient( + async_get_clientsession(hass), + entry.data[CONF_URL], + entry.data.get(CONF_TOKEN, ""), + ) + try: + await client.devices() + except GreeControllerApiError as err: + if "authentication" in str(err).lower(): + raise ConfigEntryAuthFailed(str(err)) from err + raise ConfigEntryNotReady(str(err)) from err + + coordinator = GreeControllerCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entity_map = await async_load_entity_map(hass) + entry.runtime_data = GreeControllerRuntimeData(client, coordinator, entity_map) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload the config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/gree_controller/api.py b/custom_components/gree_controller/api.py new file mode 100644 index 0000000..adbfa89 --- /dev/null +++ b/custom_components/gree_controller/api.py @@ -0,0 +1,158 @@ +"""HTTP client for the standalone GREE Controller service.""" + +from __future__ import annotations + +from typing import Any + +from aiohttp import ClientError, ClientSession + + +class GreeControllerApiError(Exception): + """Raised when the controller API cannot be used.""" + + +class GreeControllerClient: + """Small async client backed by Home Assistant's shared ClientSession.""" + + def __init__(self, session: ClientSession, base_url: str, token: str = "") -> None: + self._session = session + self._base_url = base_url.rstrip("/") + self._token = token.strip() + + @property + def base_url(self) -> str: + """Return the normalized controller URL.""" + return self._base_url + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + return headers + + async def _request(self, method: str, path: str, **kwargs: Any) -> Any: + try: + async with self._session.request( + method, + f"{self._base_url}{path}", + headers=self._headers(), + timeout=10, + **kwargs, + ) as response: + if response.status == 401: + raise GreeControllerApiError("Controller authentication failed") + if response.status >= 400: + try: + body = await response.json() + message = body.get("error", f"HTTP {response.status}") + except (ValueError, TypeError): + message = f"HTTP {response.status}" + raise GreeControllerApiError(message) + if response.status == 204: + return None + return await response.json() + except GreeControllerApiError: + raise + except (ClientError, TimeoutError) as err: + raise GreeControllerApiError(str(err)) from err + + async def health(self) -> dict[str, Any]: + """Return the public controller health payload.""" + return await self._request("GET", "/api/health") + + async def snapshot(self) -> dict[str, Any]: + """Return devices, groups and the control plan in one restricted request.""" + data = await self._request("GET", "/api/integrations/home-assistant/snapshot") + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid snapshot payload") + if not isinstance(data.get("devices"), list): + raise GreeControllerApiError("Controller returned invalid snapshot devices") + if not isinstance(data.get("control_plan"), dict): + raise GreeControllerApiError("Controller returned an invalid snapshot control plan") + if not isinstance(data.get("groups"), list): + raise GreeControllerApiError("Controller returned invalid snapshot groups") + return data + + async def devices(self) -> list[dict[str, Any]]: + """Return all controller devices.""" + data = await self._request("GET", "/api/integrations/home-assistant/devices") + if not isinstance(data, list): + raise GreeControllerApiError("Controller returned an invalid devices payload") + return data + + async def command(self, device_id: str, payload: dict[str, Any]) -> dict[str, Any]: + """Send a device command and return the updated device state.""" + return await self._request( + "POST", + f"/api/integrations/home-assistant/devices/{device_id}/command", + json=payload, + ) + + async def control_plan(self) -> dict[str, Any]: + """Return the current whole-house and zone automation plan.""" + data = await self._request("GET", "/api/integrations/home-assistant/control-plan") + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid control plan payload") + return data + + async def groups(self) -> list[dict[str, Any]]: + """Return climate groups exposed to Home Assistant.""" + data = await self._request("GET", "/api/integrations/home-assistant/groups") + if not isinstance(data, list): + raise GreeControllerApiError("Controller returned an invalid groups payload") + return data + + async def group_control(self, group_id: str, payload: dict[str, Any]) -> dict[str, Any]: + """Change one controller climate group.""" + data = await self._request( + "POST", + f"/api/integrations/home-assistant/groups/{group_id}/control", + json=payload, + ) + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid group control payload") + return data + + async def house_control(self, mode: str) -> dict[str, Any]: + """Change the whole-house thermostat mode.""" + data = await self._request( + "POST", + "/api/integrations/home-assistant/house/control", + json={"mode": mode}, + ) + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid house control payload") + return data + + async def house_preset(self, preset: str) -> dict[str, Any]: + """Change the whole-house work profile.""" + data = await self._request( + "POST", + "/api/integrations/home-assistant/house/preset", + json={"preset": preset}, + ) + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid house preset payload") + return data + + async def house_power(self, power: bool) -> dict[str, Any]: + """Turn all enabled air conditioners on or off.""" + data = await self._request( + "POST", + "/api/integrations/home-assistant/house/power", + json={"power": power}, + ) + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid house power payload") + return data + + async def zone_control(self, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]: + """Change a zone thermostat override through the controller.""" + data = await self._request( + "POST", + f"/api/integrations/home-assistant/zones/{zone_id}/control", + json=payload, + ) + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid zone payload") + return data diff --git a/custom_components/gree_controller/climate.py b/custom_components/gree_controller/climate.py new file mode 100644 index 0000000..79f58a0 --- /dev/null +++ b/custom_components/gree_controller/climate.py @@ -0,0 +1,388 @@ +"""Climate entities proxied through the standalone GREE Controller.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + SWING_OFF, + SWING_ON, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from . import GreeControllerRuntimeData +from .const import DOMAIN +from .coordinator import GreeControllerCoordinator + +_LOGGER = logging.getLogger(__name__) + +MODE_TO_HA = { + "auto": HVACMode.AUTO, + "cool": HVACMode.COOL, + "dry": HVACMode.DRY, + "fan": HVACMode.FAN_ONLY, + "heat": HVACMode.HEAT, +} +HA_TO_MODE = {value: key for key, value in MODE_TO_HA.items()} +FAN_TO_NAME = {0: "auto", 1: "low", 2: "medium_low", 3: "medium", 4: "medium_high", 5: "high"} +NAME_TO_FAN = {value: key for key, value in FAN_TO_NAME.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create climate entities for all devices exposed by the controller.""" + runtime: GreeControllerRuntimeData = entry.runtime_data + registry = er.async_get(hass) + entities: list[ClimateEntity] = [] + + for device_id, device in runtime.coordinator.data.items(): + desired_entity_id = runtime.entity_map.get(device_id) + unique_id = f"{device_id}-climate" + if desired_entity_id: + if not desired_entity_id.startswith("climate."): + raise ConfigEntryError(f"Mapped entity ID must use the climate domain: {desired_entity_id}") + existing = registry.async_get(desired_entity_id) + if existing and not (existing.platform == DOMAIN and existing.unique_id == unique_id): + raise ConfigEntryError( + f"Entity ID {desired_entity_id} is still reserved by integration {existing.platform}. " + "Disable/remove the previous GREE integration and remove its entity registry entry before takeover." + ) + if hass.states.get(desired_entity_id) is not None and existing is None: + raise ConfigEntryError( + f"Entity ID {desired_entity_id} is still active in Home Assistant. " + "Unload the previous integration before takeover." + ) + entities.append( + GreeControllerClimate(runtime.coordinator, device_id, desired_entity_id) + ) + + for zone in runtime.coordinator.plan.get("zones", []): + zone_id = str(zone.get("zone_id") or "").strip() + if zone_id: + _ensure_zone_thermostat_entity_id(registry, zone) + entities.append(GreeControllerZoneClimate(runtime.coordinator, zone_id)) + + async_add_entities(entities) + + +def _ensure_zone_thermostat_entity_id( + registry: er.EntityRegistry, zone: dict[str, Any] +) -> None: + """Keep zone climate entity IDs stable and descriptive. + + Zone climate entities use ``climate._thermostat``. Existing + registry entries are migrated to the same scheme, which also fixes stale + IDs left behind after a zone was renamed (for example ``climate.jan_2`` + for a zone currently named Igor). + """ + zone_id = str(zone.get("zone_id") or "").strip() + if not zone_id: + return + + unique_id = f"{zone_id}-zone-climate" + current_entity_id = registry.async_get_entity_id("climate", DOMAIN, unique_id) + if current_entity_id is None: + return + + zone_name = str(zone.get("zone_name") or zone_id).strip() or zone_id + object_id = slugify(zone_name) or slugify(zone_id) or "zone" + desired_entity_id = f"climate.{object_id}_thermostat" + if current_entity_id == desired_entity_id: + return + + occupied = registry.async_get(desired_entity_id) + if occupied is not None and occupied.entity_id != current_entity_id: + _LOGGER.warning( + "Cannot rename zone thermostat %s to %s because that entity ID is already in use", + current_entity_id, + desired_entity_id, + ) + return + + registry.async_update_entity(current_entity_id, new_entity_id=desired_entity_id) + _LOGGER.info( + "Renamed zone thermostat entity %s to %s", + current_entity_id, + desired_entity_id, + ) + + +class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity): + """Home Assistant climate entity controlled through the Rust service.""" + + _attr_has_entity_name = True + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = 8.0 + _attr_max_temp = 30.0 + _attr_target_temperature_step = 1.0 + _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY, HVACMode.FAN_ONLY] + _attr_fan_modes = list(NAME_TO_FAN) + _attr_swing_modes = [SWING_OFF, SWING_ON] + _attr_swing_horizontal_modes = [SWING_OFF, SWING_ON] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, + coordinator: GreeControllerCoordinator, + device_id: str, + requested_entity_id: str | None, + ) -> None: + super().__init__(coordinator) + self._device_id = device_id + self._attr_unique_id = f"{device_id}-climate" + self._attr_name = "Direct control" + if requested_entity_id: + # This is intentionally limited to same-domain takeover migrations. + # The setup guard above prevents accidental collisions. + self.entity_id = requested_entity_id + + @property + def _device(self) -> dict[str, Any]: + return self.coordinator.data.get(self._device_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._device.get("online", False)) + + @property + def device_info(self) -> DeviceInfo: + device = self._device + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=str(device.get("name") or self._device_id), + manufacturer="GREE", + model=str(device.get("model") or "GREE HVAC"), + sw_version=str(device.get("firmware") or "") or None, + ) + + @property + def current_temperature(self) -> float | None: + value = self._device.get("current_temperature") + return float(value) if value is not None else None + + @property + def target_temperature(self) -> float | None: + value = self._device.get("target_temperature") + return float(value) if value is not None else None + + @property + def hvac_mode(self) -> HVACMode: + device = self._device + if not device.get("power", False): + return HVACMode.OFF + return MODE_TO_HA.get(str(device.get("mode", "auto")), HVACMode.AUTO) + + @property + def fan_mode(self) -> str: + return FAN_TO_NAME.get(int(self._device.get("fan_speed", 0)), "auto") + + @property + def swing_mode(self) -> str: + return SWING_ON if self._device.get("swing_vertical", False) else SWING_OFF + + @property + def swing_horizontal_mode(self) -> str: + return SWING_ON if self._device.get("swing_horizontal", False) else SWING_OFF + + @property + def extra_state_attributes(self) -> dict[str, Any]: + device = self._device + return { + "controller_device_id": self._device_id, + "controller_online": bool(device.get("online", False)), + "quiet": bool(device.get("quiet", False)), + "turbo": bool(device.get("turbo", False)), + "light": bool(device.get("light", False)), + "xfan": bool(device.get("xfan", False)), + "air": bool(device.get("air", False)), + "health": bool(device.get("health", False)), + "sleep": bool(device.get("sleep", False)), + "last_seen": device.get("last_seen"), + } + + async def _command(self, payload: dict[str, Any]) -> None: + await self.coordinator.async_device_command(self._device_id, payload) + + async def async_turn_on(self) -> None: + await self._command({"power": True}) + + async def async_turn_off(self) -> None: + await self._command({"power": False}) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + await self._command({"power": False}) + return + mode = HA_TO_MODE.get(hvac_mode) + if mode is None: + return + await self._command({"power": True, "mode": mode}) + + async def async_set_temperature(self, **kwargs: Any) -> None: + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is not None: + await self._command({"target_temperature": float(temperature)}) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + if fan_mode in NAME_TO_FAN: + await self._command({"fan_speed": NAME_TO_FAN[fan_mode]}) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + await self._command({"swing_vertical": swing_mode == SWING_ON}) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + await self._command({"swing_horizontal": swing_horizontal_mode == SWING_ON}) + +class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity): + """Full climate entity for a controller thermostat zone.""" + + _attr_has_entity_name = True + _attr_name = "Thermostat" + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = 8.0 + _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 + ) + + def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None: + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{zone_id}-zone-climate" + + @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 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 current_temperature(self) -> float | None: + value = self._zone.get("current_temperature") + return float(value) if value is not None else None + + @property + def target_temperature(self) -> float | None: + value = self._zone.get("target_temperature") + if value is None: + value = self._zone.get("setpoint") + return float(value) if value is not None else None + + @property + def hvac_mode(self) -> HVACMode: + zone = self._zone + if not zone.get("enabled", False) or zone.get("local_thermostat_power") is False: + return HVACMode.OFF + if zone.get("inherit_house_mode", True): + 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))), + "local_thermostat_power": zone.get("local_thermostat_power"), + "local_thermostat_resume_at": zone.get("local_thermostat_resume_at"), + "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", + "active_preset": zone.get("preset"), + "preset_override": zone.get("preset_override"), + "demand": bool(zone.get("demand", False)), + "device_setpoint": zone.get("device_setpoint"), + "temperature_source": zone.get("control_source"), + "control_owner": zone.get("control_owner"), + "control_source": zone.get("control_command_source"), + "control_since": zone.get("control_since"), + "resume_at": zone.get("resume_at"), + "control_reason": zone.get("control_reason"), + "blocked_reason": zone.get("blocked_reason"), + "lockout_until": zone.get("lockout_until"), + "desired_power": zone.get("desired_power"), + "desired_mode": zone.get("desired_mode"), + "actual_power": zone.get("actual_power"), + "actual_mode": zone.get("actual_mode"), + "actual_setpoint": zone.get("actual_setpoint"), + "current_schedule": zone.get("current_schedule_name"), + } + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + payload = {"power": False} + elif hvac_mode == HVACMode.AUTO: + payload = {"power": True, "mode": "house"} + elif hvac_mode == HVACMode.COOL: + payload = {"power": True, "mode": "cool"} + elif hvac_mode == HVACMode.HEAT: + payload = {"power": True, "mode": "heat"} + else: + return + 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.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.async_zone_control(self._zone_id, {"power": True}) + + async def async_turn_off(self) -> None: + await self.coordinator.async_zone_control(self._zone_id, {"power": False}) + diff --git a/custom_components/gree_controller/config_flow.py b/custom_components/gree_controller/config_flow.py new file mode 100644 index 0000000..1091bad --- /dev/null +++ b/custom_components/gree_controller/config_flow.py @@ -0,0 +1,46 @@ +"""UI configuration flow for GREE Controller.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import GreeControllerApiError, GreeControllerClient +from .const import CONF_TOKEN, CONF_URL, DOMAIN + + +class GreeControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Configure a standalone GREE Controller instance.""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle the initial connection form.""" + errors: dict[str, str] = {} + if user_input is not None: + url = str(user_input[CONF_URL]).strip().rstrip("/") + token = str(user_input[CONF_TOKEN]).strip() + client = GreeControllerClient(async_get_clientsession(self.hass), url, token) + try: + await client.devices() + except GreeControllerApiError as err: + errors["base"] = "invalid_auth" if "authentication" in str(err).lower() else "cannot_connect" + else: + await self.async_set_unique_id("gree-controller") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="GREE Controller", + data={CONF_URL: url, CONF_TOKEN: token}, + ) + + schema = vol.Schema( + { + vol.Required(CONF_URL, default="http://gree-controller:8787"): str, + vol.Required(CONF_TOKEN): str, + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/custom_components/gree_controller/const.py b/custom_components/gree_controller/const.py new file mode 100644 index 0000000..f8af1ae --- /dev/null +++ b/custom_components/gree_controller/const.py @@ -0,0 +1,11 @@ +"""Constants for the GREE Controller Home Assistant integration.""" + +from homeassistant.const import Platform + +DOMAIN = "gree_controller" +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH] + +CONF_URL = "url" +CONF_TOKEN = "token" +ENTITY_MAP_FILE = "gree_controller_entities.json" +DEFAULT_SCAN_INTERVAL_SECONDS = 10 diff --git a/custom_components/gree_controller/coordinator.py b/custom_components/gree_controller/coordinator.py new file mode 100644 index 0000000..fc3634d --- /dev/null +++ b/custom_components/gree_controller/coordinator.py @@ -0,0 +1,193 @@ +"""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"]))) + 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() diff --git a/custom_components/gree_controller/entity_map.py b/custom_components/gree_controller/entity_map.py new file mode 100644 index 0000000..30c7605 --- /dev/null +++ b/custom_components/gree_controller/entity_map.py @@ -0,0 +1,34 @@ +"""Load optional legacy entity ID mappings for GREE Controller.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from homeassistant.core import HomeAssistant + +from .const import ENTITY_MAP_FILE + + +def _load(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as handle: + data: Any = json.load(handle) + items = data.get("entities", []) if isinstance(data, dict) else [] + result: dict[str, str] = {} + for item in items: + if not isinstance(item, dict): + continue + device_id = str(item.get("device_id", "")).strip() + entity_id = str(item.get("entity_id", "")).strip() + if device_id and entity_id.startswith("climate."): + result[device_id] = entity_id + return result + + +async def async_load_entity_map(hass: HomeAssistant) -> dict[str, str]: + """Load the optional mapping file without blocking Home Assistant's loop.""" + path = Path(hass.config.path(ENTITY_MAP_FILE)) + return await hass.async_add_executor_job(_load, path) diff --git a/custom_components/gree_controller/icon.png b/custom_components/gree_controller/icon.png new file mode 100644 index 0000000..6105675 Binary files /dev/null and b/custom_components/gree_controller/icon.png differ diff --git a/custom_components/gree_controller/manifest.json b/custom_components/gree_controller/manifest.json new file mode 100644 index 0000000..b3d5afd --- /dev/null +++ b/custom_components/gree_controller/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "gree_controller", + "name": "GREE Controller", + "version": "0.13.9", + "config_flow": true, + "integration_type": "hub", + "iot_class": "local_polling", + "single_config_entry": true +} diff --git a/custom_components/gree_controller/number.py b/custom_components/gree_controller/number.py new file mode 100644 index 0000000..5495d78 --- /dev/null +++ b/custom_components/gree_controller/number.py @@ -0,0 +1,81 @@ +"""Writable zone setpoint numbers for GREE Controller.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GreeControllerRuntimeData +from .const import DOMAIN +from .coordinator import GreeControllerCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create one writable target-temperature number per zone.""" + runtime: GreeControllerRuntimeData = entry.runtime_data + entities = [ + GreeControllerZoneTargetNumber(runtime.coordinator, str(zone["zone_id"])) + for zone in runtime.coordinator.plan.get("zones", []) + if zone.get("zone_id") + ] + async_add_entities(entities) + + +class GreeControllerZoneTargetNumber(CoordinatorEntity[GreeControllerCoordinator], NumberEntity): + """Zone target override controlled through the standalone service.""" + + _attr_has_entity_name = True + _attr_name = "Target temperature" + _attr_device_class = NumberDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_native_min_value = 8.0 + _attr_native_max_value = 30.0 + _attr_native_step = 0.5 + + def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None: + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{zone_id}-target-temperature" + + @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 native_value(self) -> float | None: + value = self._zone.get("target_temperature") + if value is None: + value = self._zone.get("setpoint") + return float(value) if value is not None else None + + @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"), + ) + + async def async_set_native_value(self, value: float) -> None: + await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(value)}) diff --git a/custom_components/gree_controller/select.py b/custom_components/gree_controller/select.py new file mode 100644 index 0000000..b3209fb --- /dev/null +++ b/custom_components/gree_controller/select.py @@ -0,0 +1,254 @@ +"""Whole-house, zone and climate-group thermostat selectors for GREE Controller.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GreeControllerRuntimeData +from .const import DOMAIN +from .coordinator import GreeControllerCoordinator + +HOUSE_MODE_LABELS = { + "off": "Do not control", + "cool": "Cooling", + "heat": "Heating", +} +HOUSE_MODE_VALUES = {label: value for value, label in HOUSE_MODE_LABELS.items()} + +HOUSE_PRESET_LABELS = { + "auto": "Auto schedule", + "comfort": "Comfort", + "sleep": "Sleep", + "away": "Away", +} +HOUSE_PRESET_VALUES = {label: value for value, label in HOUSE_PRESET_LABELS.items()} + +GROUP_MODE_LABELS = { + "house": "Global", + "cool": "Cooling", + "heat": "Heating", +} +GROUP_MODE_VALUES = {label: value for value, label in GROUP_MODE_LABELS.items()} +GROUP_PRESET_LABELS = HOUSE_PRESET_LABELS +GROUP_PRESET_VALUES = HOUSE_PRESET_VALUES + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """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)) + async_add_entities(entities) + + +class _GreeControllerHouseSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity): + """Base for controller-level selectors.""" + + _attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, "controller")}, + name="GREE Controller", + manufacturer="GREE Controller", + model="Local controller", + ) + + +class GreeControllerHouseModeSelect(_GreeControllerHouseSelect): + """Select the smart thermostat house mode.""" + + _attr_name = "Thermostat mode" + _attr_unique_id = "house-thermostat-mode" + _attr_options = list(HOUSE_MODE_VALUES) + + @property + def current_option(self) -> str | None: + value = str(self.coordinator.plan.get("house_mode") or "") + return HOUSE_MODE_LABELS.get(value) + + async def async_select_option(self, option: str) -> None: + value = HOUSE_MODE_VALUES.get(option) + if value is None: + return + await self.coordinator.client.house_control(value) + await self.coordinator.async_request_refresh() + + +class GreeControllerHousePresetSelect(_GreeControllerHouseSelect): + """Select the whole-house thermostat work profile.""" + + _attr_name = "Work profile" + _attr_unique_id = "house-work-profile" + _attr_options = list(HOUSE_PRESET_VALUES) + + @property + def current_option(self) -> str | None: + value = self.coordinator.plan.get("house_preset") + return HOUSE_PRESET_LABELS.get(str(value)) if value is not None else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return {"mixed_zone_profiles": self.coordinator.plan.get("house_preset") is None} + + async def async_select_option(self, option: str) -> None: + value = HOUSE_PRESET_VALUES.get(option) + if value is None: + return + await self.coordinator.client.house_preset(value) + 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.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator) + self._group_id = group_id + + @property + def _group(self) -> dict[str, Any]: + return self.coordinator.groups.get(self._group_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._group) + + @property + def device_info(self) -> DeviceInfo: + group = self._group + return DeviceInfo( + identifiers={(DOMAIN, f"group:{self._group_id}")}, + name=str(group.get("name") or self._group_id), + manufacturer="GREE Controller", + model="Climate group", + via_device=(DOMAIN, "controller"), + ) + + +class GreeControllerGroupModeSelect(_GreeControllerGroupSelect): + """Select the thermostat mode policy for one climate group.""" + + _attr_name = "Thermostat mode" + _attr_options = list(GROUP_MODE_VALUES) + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator, group_id) + self._attr_unique_id = f"{group_id}-group-thermostat-mode" + + @property + def current_option(self) -> str | None: + return GROUP_MODE_LABELS.get(str(self._group.get("mode") or "")) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return { + "mixed_group_mode": self._group.get("mode") == "mixed", + "global_house_mode": self._group.get("house_mode"), + } + + async def async_select_option(self, option: str) -> None: + value = GROUP_MODE_VALUES.get(option) + if value is None: + return + await self.coordinator.client.group_control(self._group_id, {"mode": value}) + await self.coordinator.async_request_refresh() + + +class GreeControllerGroupPresetSelect(_GreeControllerGroupSelect): + """Select the work profile for one climate group.""" + + _attr_name = "Work profile" + _attr_options = list(GROUP_PRESET_VALUES) + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator, group_id) + self._attr_unique_id = f"{group_id}-group-work-profile" + + @property + def current_option(self) -> str | None: + return GROUP_PRESET_LABELS.get(str(self._group.get("preset") or "")) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return {"mixed_group_profiles": self._group.get("preset") == "mixed"} + + async def async_select_option(self, option: str) -> None: + value = GROUP_PRESET_VALUES.get(option) + if value is None: + return + await self.coordinator.client.group_control(self._group_id, {"preset": value}) + await self.coordinator.async_request_refresh() diff --git a/custom_components/gree_controller/sensor.py b/custom_components/gree_controller/sensor.py new file mode 100644 index 0000000..24ed3f2 --- /dev/null +++ b/custom_components/gree_controller/sensor.py @@ -0,0 +1,206 @@ +"""House, zone and climate-group plan sensors exposed by GREE Controller.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GreeControllerRuntimeData +from .const import DOMAIN +from .coordinator import GreeControllerCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create whole-house and per-zone automation-plan sensors.""" + runtime: GreeControllerRuntimeData = entry.runtime_data + entities: list[SensorEntity] = [GreeControllerHousePlanSensor(runtime.coordinator)] + for zone in runtime.coordinator.plan.get("zones", []): + if zone.get("zone_id"): + entities.append(GreeControllerZonePlanSensor(runtime.coordinator, str(zone["zone_id"]))) + for group_id in runtime.coordinator.groups: + entities.append(GreeControllerGroupPlanSensor(runtime.coordinator, group_id)) + async_add_entities(entities) + + +class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity): + """Summary of the current whole-house control plan.""" + + _attr_has_entity_name = True + _attr_name = "Automation plan" + _attr_unique_id = "house-control-plan" + _unrecorded_attributes = frozenset({"next_events", "rules"}) + + @property + def native_value(self) -> str: + return str(self.coordinator.plan.get("house_mode") or "unknown") + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, "controller")}, + name="GREE Controller", + manufacturer="GREE Controller", + model="Local controller", + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + plan = self.coordinator.plan + zones = plan.get("zones", []) + groups = list(self.coordinator.groups.values()) + return { + "generated_at": plan.get("generated_at"), + "outdoor_temperature": plan.get("outdoor_temperature"), + "control_strategy": plan.get("control_strategy"), + "work_profile": plan.get("house_preset"), + "all_units_powered": bool(self.coordinator.data) and all( + bool(device.get("power")) for device in self.coordinator.data.values() if device.get("enabled", True) + ), + "enabled_zones": sum(bool(zone.get("enabled")) 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), + "next_events": plan.get("next_events", []), + "rules": plan.get("rules", []), + } + + +class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity): + """Readable automation plan for one controller zone.""" + + _attr_has_entity_name = True + _attr_name = "Control plan" + _unrecorded_attributes = frozenset({"next_events"}) + + def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None: + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{zone_id}-control-plan" + + @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 native_value(self) -> str: + 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 + 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]: + zone = self._zone + return { + "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"), + "control_source": zone.get("control_source"), + "manual_override_until": zone.get("manual_override_until"), + "current_schedule": zone.get("current_schedule_name"), + "next_events": zone.get("next_events", []), + } + + +class GreeControllerGroupPlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity): + """Readable status and automation plan for one climate group.""" + + _attr_has_entity_name = True + _attr_name = "Control plan" + _unrecorded_attributes = frozenset({"next_events", "zone_ids", "zone_names", "members"}) + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator) + self._group_id = group_id + self._attr_unique_id = f"{group_id}-group-control-plan" + + @property + def _group(self) -> dict[str, Any]: + return self.coordinator.groups.get(self._group_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._group) + + @property + def native_value(self) -> str: + group = self._group + if not group.get("power_enabled", False): + return "off" + if group.get("mode") == "house" and group.get("house_mode") == "off": + return "paused" + return "requesting" if int(group.get("demanding_zones") or 0) > 0 else "satisfied" + + @property + def device_info(self) -> DeviceInfo: + group = self._group + return DeviceInfo( + identifiers={(DOMAIN, f"group:{self._group_id}")}, + name=str(group.get("name") or self._group_id), + manufacturer="GREE Controller", + model="Climate group", + via_device=(DOMAIN, "controller"), + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + group = self._group + return { + "group_id": self._group_id, + "power_enabled": bool(group.get("power_enabled", False)), + "effective_power": bool(group.get("effective_power", False)), + "mode": group.get("mode"), + "global_house_mode": group.get("house_mode"), + "work_profile": group.get("preset"), + "zone_count": group.get("zone_count"), + "enabled_zones": group.get("enabled_zones"), + "active_zones": group.get("active_zones"), + "demanding_zones": group.get("demanding_zones"), + "device_count": group.get("device_count"), + "online_devices": group.get("online_devices"), + "current_temperature": group.get("current_temperature"), + "zone_ids": group.get("zone_ids", []), + "zone_names": group.get("zone_names", []), + "members": group.get("members", []), + "next_events": group.get("next_events", []), + } diff --git a/custom_components/gree_controller/switch.py b/custom_components/gree_controller/switch.py new file mode 100644 index 0000000..2bd3291 --- /dev/null +++ b/custom_components/gree_controller/switch.py @@ -0,0 +1,234 @@ +"""Writable house, group, zone and device switches for GREE Controller.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GreeControllerRuntimeData +from .const import DOMAIN +from .coordinator import GreeControllerCoordinator + + +DEVICE_FEATURE_SWITCHES = [ + ("light", "supports_light", "Panel light"), + ("quiet", "supports_quiet", "Quiet"), + ("turbo", "supports_turbo", "Turbo"), + ("xfan", "supports_xfan", "X-FAN"), + ("air", "supports_air", "Air"), + ("health", "supports_health", "Health"), + ("sleep", "supports_sleep", "Sleep"), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create one enabled switch per controller zone.""" + runtime: GreeControllerRuntimeData = entry.runtime_data + entities: list[SwitchEntity] = [ + GreeControllerHousePowerSwitch(runtime.coordinator), + *[ + GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"])) + for zone in runtime.coordinator.plan.get("zones", []) + if zone.get("zone_id") + ], + *[ + GreeControllerGroupPowerSwitch(runtime.coordinator, group_id) + for group_id in runtime.coordinator.groups + ], + ] + for device_id, device in runtime.coordinator.data.items(): + for field, support_field, name in DEVICE_FEATURE_SWITCHES: + if device.get(support_field) is True: + entities.append(GreeControllerDeviceFeatureSwitch(runtime.coordinator, device_id, field, support_field, name)) + async_add_entities(entities) + + +class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity): + """Master power action for all enabled indoor units.""" + + _attr_has_entity_name = True + _attr_name = "All air conditioners" + _attr_unique_id = "house-power-all" + + @property + def is_on(self) -> bool: + devices = [device for device in self.coordinator.data.values() if device.get("enabled", True)] + return bool(devices) and all(bool(device.get("power")) for device in devices) + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, "controller")}, + name="GREE Controller", + manufacturer="GREE Controller", + model="Local controller", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.coordinator.client.house_power(True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.client.house_power(False) + await self.coordinator.async_request_refresh() + + +class GreeControllerGroupPowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity): + """Power a controller climate group on or off.""" + + _attr_has_entity_name = True + _attr_name = "Power" + + def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None: + super().__init__(coordinator) + self._group_id = group_id + self._attr_unique_id = f"{group_id}-group-power" + + @property + def _group(self) -> dict[str, Any]: + return self.coordinator.groups.get(self._group_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._group) + + @property + def is_on(self) -> bool: + return bool(self._group.get("power_enabled", False)) + + @property + def device_info(self) -> DeviceInfo: + group = self._group + return DeviceInfo( + identifiers={(DOMAIN, f"group:{self._group_id}")}, + name=str(group.get("name") or self._group_id), + manufacturer="GREE Controller", + model="Climate group", + via_device=(DOMAIN, "controller"), + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return { + "effective_power": bool(self._group.get("effective_power", False)), + "zone_count": self._group.get("zone_count"), + } + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.coordinator.client.group_control(self._group_id, {"power": True}) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.client.group_control(self._group_id, {"power": False}) + await self.coordinator.async_request_refresh() + + +class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity): + """Enable or disable a controller thermostat zone.""" + + _attr_has_entity_name = True + _attr_name = "Enabled" + + def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None: + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{zone_id}-enabled" + + @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 is_on(self) -> bool: + return bool(self._zone.get("enabled", False)) + + @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"), + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.coordinator.async_zone_control(self._zone_id, {"enabled": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + 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.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: GreeControllerCoordinator, + device_id: str, + field: str, + support_field: str, + name: str, + ) -> None: + super().__init__(coordinator) + self._device_id = device_id + self._field = field + self._support_field = support_field + self._attr_name = name + self._attr_unique_id = f"{device_id}-{field}" + + @property + def _device(self) -> dict[str, Any]: + return self.coordinator.data.get(self._device_id, {}) + + @property + def available(self) -> bool: + return ( + super().available + and bool(self._device.get("online", False)) + and self._device.get(self._support_field) is True + ) + + @property + def is_on(self) -> bool: + return bool(self._device.get(self._field, False)) + + @property + def device_info(self) -> DeviceInfo: + device = self._device + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=str(device.get("name") or self._device_id), + manufacturer="GREE", + model=str(device.get("model") or "GREE HVAC"), + sw_version=str(device.get("firmware") or "") or None, + ) + + async def _set(self, value: bool) -> None: + await self.coordinator.async_device_command(self._device_id, {self._field: value}) + + async def async_turn_on(self, **kwargs: Any) -> None: + await self._set(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self._set(False) + diff --git a/custom_components/gree_controller/translations/en.json b/custom_components/gree_controller/translations/en.json new file mode 100644 index 0000000..898f7a1 --- /dev/null +++ b/custom_components/gree_controller/translations/en.json @@ -0,0 +1,22 @@ +{ + "title": "GREE Controller", + "config": { + "step": { + "user": { + "title": "Connect to GREE Controller", + "description": "Connect Home Assistant to the standalone Rust controller. Device commands will be proxied through the controller instead of the built-in GREE integration.", + "data": { + "url": "Controller URL", + "token": "API token" + } + } + }, + "error": { + "cannot_connect": "Cannot connect to GREE Controller", + "invalid_auth": "Invalid controller API token" + }, + "abort": { + "already_configured": "GREE Controller is already configured" + } + } +} diff --git a/custom_components/gree_controller/translations/pl.json b/custom_components/gree_controller/translations/pl.json new file mode 100644 index 0000000..bf877a9 --- /dev/null +++ b/custom_components/gree_controller/translations/pl.json @@ -0,0 +1,22 @@ +{ + "title": "GREE Controller", + "config": { + "step": { + "user": { + "title": "Połącz z GREE Controller", + "description": "Połącz Home Assistant z niezależnym kontrolerem Rust. Polecenia urządzeń będą przechodziły przez kontroler zamiast wbudowanej integracji GREE.", + "data": { + "url": "Adres URL kontrolera", + "token": "Token API" + } + } + }, + "error": { + "cannot_connect": "Nie można połączyć się z GREE Controller", + "invalid_auth": "Nieprawidłowy token API kontrolera" + }, + "abort": { + "already_configured": "GREE Controller jest już skonfigurowany" + } + } +} diff --git a/gree-controller/README.md b/gree-controller/README.md index f1a30c5..f67375f 100644 --- a/gree-controller/README.md +++ b/gree-controller/README.md @@ -8,4 +8,6 @@ For VLAN deployments, configure VLAN interfaces on the Home Assistant OS host. T Full documentation: [DOCS.md](./DOCS.md). +The same Git repository also contains the optional `custom_components/gree_controller` integration. It is distributed with the repository but is not installed automatically with the add-on. + Repository: https://git.linuxiarz.pl/gru/gree-controller-ha-addon/