GREE Controller 0.13.9
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.<zone_name>_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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
@@ -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()
|
||||
@@ -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", []),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user