469 lines
18 KiB
Python
469 lines
18 KiB
Python
"""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()}
|
|
|
|
VERTICAL_SWING_TO_VALUE = {
|
|
SWING_OFF: 0,
|
|
SWING_ON: 1,
|
|
"fixed_upper": 2,
|
|
"fixed_upper_middle": 3,
|
|
"fixed_middle": 4,
|
|
"fixed_lower_middle": 5,
|
|
"fixed_lower": 6,
|
|
"swing_upper": 7,
|
|
"swing_upper_middle": 8,
|
|
"swing_middle": 9,
|
|
"swing_lower_middle": 10,
|
|
"swing_lower": 11,
|
|
}
|
|
HORIZONTAL_SWING_TO_VALUE = {
|
|
SWING_OFF: 0,
|
|
SWING_ON: 1,
|
|
"fixed_left": 2,
|
|
"fixed_left_middle": 3,
|
|
"fixed_middle": 4,
|
|
"fixed_right_middle": 5,
|
|
"fixed_right": 6,
|
|
}
|
|
VALUE_TO_VERTICAL_SWING = {value: mode for mode, value in VERTICAL_SWING_TO_VALUE.items()}
|
|
VALUE_TO_HORIZONTAL_SWING = {value: mode for mode, value in HORIZONTAL_SWING_TO_VALUE.items()}
|
|
|
|
|
|
def _louver_mode(raw: Any, modes: dict[int, str]) -> str:
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError):
|
|
value = 0
|
|
return modes.get(value, SWING_OFF)
|
|
|
|
|
|
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_translation_key = "direct_control"
|
|
_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 = list(VERTICAL_SWING_TO_VALUE)
|
|
_attr_swing_horizontal_modes = list(HORIZONTAL_SWING_TO_VALUE)
|
|
_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 _louver_mode(self._device.get("swing_vertical", 0), VALUE_TO_VERTICAL_SWING)
|
|
|
|
@property
|
|
def swing_horizontal_mode(self) -> str:
|
|
return _louver_mode(self._device.get("swing_horizontal", 0), VALUE_TO_HORIZONTAL_SWING)
|
|
|
|
@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)),
|
|
"vertical_louver_position": int(device.get("swing_vertical", 0) or 0),
|
|
"horizontal_louver_position": int(device.get("swing_horizontal", 0) or 0),
|
|
"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:
|
|
value = VERTICAL_SWING_TO_VALUE.get(swing_mode)
|
|
if value is not None:
|
|
await self._command({"swing_vertical": value})
|
|
|
|
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
|
value = HORIZONTAL_SWING_TO_VALUE.get(swing_horizontal_mode)
|
|
if value is not None:
|
|
await self._command({"swing_horizontal": value})
|
|
|
|
class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity):
|
|
"""Full climate entity for a controller thermostat zone."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_translation_key = "zone_thermostat"
|
|
_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_swing_modes = list(VERTICAL_SWING_TO_VALUE)
|
|
_attr_swing_horizontal_modes = list(HORIZONTAL_SWING_TO_VALUE)
|
|
|
|
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 _device(self) -> dict[str, Any]:
|
|
device_id = str(self._zone.get("device_id") or "").strip()
|
|
return self.coordinator.data.get(device_id, {}) if device_id else {}
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
return super().available and bool(self._zone)
|
|
|
|
@property
|
|
def supported_features(self) -> ClimateEntityFeature:
|
|
features = (
|
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
|
| ClimateEntityFeature.PRESET_MODE
|
|
| ClimateEntityFeature.TURN_ON
|
|
| ClimateEntityFeature.TURN_OFF
|
|
)
|
|
capabilities = self._device.get("capabilities") or {}
|
|
if capabilities.get("vertical_swing", True) is not False:
|
|
features |= ClimateEntityFeature.SWING_MODE
|
|
if capabilities.get("horizontal_swing", True) is not False:
|
|
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
|
return features
|
|
|
|
@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 swing_mode(self) -> str:
|
|
return _louver_mode(self._device.get("swing_vertical", 0), VALUE_TO_VERTICAL_SWING)
|
|
|
|
@property
|
|
def swing_horizontal_mode(self) -> str:
|
|
return _louver_mode(self._device.get("swing_horizontal", 0), VALUE_TO_HORIZONTAL_SWING)
|
|
|
|
@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"),
|
|
"vertical_louver_position": int(self._device.get("swing_vertical", 0) or 0),
|
|
"horizontal_louver_position": int(self._device.get("swing_horizontal", 0) or 0),
|
|
}
|
|
|
|
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_set_swing_mode(self, swing_mode: str) -> None:
|
|
device_id = str(self._zone.get("device_id") or "").strip()
|
|
value = VERTICAL_SWING_TO_VALUE.get(swing_mode)
|
|
if device_id and value is not None:
|
|
await self.coordinator.async_device_command(device_id, {"swing_vertical": value})
|
|
|
|
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
|
device_id = str(self._zone.get("device_id") or "").strip()
|
|
value = HORIZONTAL_SWING_TO_VALUE.get(swing_horizontal_mode)
|
|
if device_id and value is not None:
|
|
await self.coordinator.async_device_command(device_id, {"swing_horizontal": value})
|
|
|
|
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})
|
|
|