mirror of
https://github.com/home-assistant/core.git
synced 2025-10-29 13:39:29 +00:00
302 lines
9.7 KiB
Python
302 lines
9.7 KiB
Python
"""Platform for Control4 Climate/Thermostat."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
import logging
|
|
from typing import Any
|
|
|
|
from pyControl4.climate import C4Climate
|
|
from pyControl4.error_handling import C4Exception
|
|
|
|
from homeassistant.components.climate import (
|
|
ATTR_TARGET_TEMP_HIGH,
|
|
ATTR_TARGET_TEMP_LOW,
|
|
ClimateEntity,
|
|
ClimateEntityFeature,
|
|
HVACAction,
|
|
HVACMode,
|
|
)
|
|
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
|
|
from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
|
|
from .const import CONTROL4_ENTITY_TYPE
|
|
from .director_utils import update_variables_for_config_entry
|
|
from .entity import Control4Entity
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
CONTROL4_CATEGORY = "comfort"
|
|
|
|
# Control4 variable names
|
|
CONTROL4_HVAC_STATE = "HVAC_STATE"
|
|
CONTROL4_HVAC_MODE = "HVAC_MODE"
|
|
CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F"
|
|
CONTROL4_HUMIDITY = "HUMIDITY"
|
|
CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F"
|
|
CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F"
|
|
|
|
VARIABLES_OF_INTEREST = {
|
|
CONTROL4_HVAC_STATE,
|
|
CONTROL4_HVAC_MODE,
|
|
CONTROL4_CURRENT_TEMPERATURE,
|
|
CONTROL4_HUMIDITY,
|
|
CONTROL4_COOL_SETPOINT,
|
|
CONTROL4_HEAT_SETPOINT,
|
|
}
|
|
|
|
# Map Control4 HVAC modes to Home Assistant
|
|
C4_TO_HA_HVAC_MODE = {
|
|
"Off": HVACMode.OFF,
|
|
"Cool": HVACMode.COOL,
|
|
"Heat": HVACMode.HEAT,
|
|
"Auto": HVACMode.HEAT_COOL,
|
|
}
|
|
|
|
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
|
|
|
# Map Control4 HVAC state to Home Assistant HVAC action
|
|
C4_TO_HA_HVAC_ACTION = {
|
|
"heating": HVACAction.HEATING,
|
|
"cooling": HVACAction.COOLING,
|
|
"idle": HVACAction.IDLE,
|
|
"off": HVACAction.OFF,
|
|
}
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: Control4ConfigEntry,
|
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
) -> None:
|
|
"""Set up Control4 thermostats from a config entry."""
|
|
runtime_data = entry.runtime_data
|
|
|
|
async def async_update_data() -> dict[int, dict[str, Any]]:
|
|
"""Fetch data from Control4 director for thermostats."""
|
|
try:
|
|
return await update_variables_for_config_entry(
|
|
hass, entry, VARIABLES_OF_INTEREST
|
|
)
|
|
except C4Exception as err:
|
|
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
|
|
|
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
|
|
hass,
|
|
_LOGGER,
|
|
name="climate",
|
|
update_method=async_update_data,
|
|
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
|
config_entry=entry,
|
|
)
|
|
|
|
# Fetch initial data so we have data when entities subscribe
|
|
await coordinator.async_refresh()
|
|
|
|
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
|
|
entity_list = []
|
|
for item in items_of_category:
|
|
try:
|
|
if item["type"] == CONTROL4_ENTITY_TYPE:
|
|
item_name = item["name"]
|
|
item_id = item["id"]
|
|
item_parent_id = item["parentId"]
|
|
item_manufacturer = None
|
|
item_device_name = None
|
|
item_model = None
|
|
|
|
for parent_item in items_of_category:
|
|
if parent_item["id"] == item_parent_id:
|
|
item_manufacturer = parent_item.get("manufacturer")
|
|
item_device_name = parent_item.get("roomName")
|
|
item_model = parent_item.get("model")
|
|
else:
|
|
continue
|
|
except KeyError:
|
|
_LOGGER.exception(
|
|
"Unknown device properties received from Control4: %s",
|
|
item,
|
|
)
|
|
continue
|
|
|
|
# Skip if we don't have data for this thermostat
|
|
if item_id not in coordinator.data:
|
|
_LOGGER.warning(
|
|
"Couldn't get climate state data for %s (ID: %s), skipping setup",
|
|
item_name,
|
|
item_id,
|
|
)
|
|
continue
|
|
|
|
entity_list.append(
|
|
Control4Climate(
|
|
runtime_data,
|
|
coordinator,
|
|
item_name,
|
|
item_id,
|
|
item_device_name,
|
|
item_manufacturer,
|
|
item_model,
|
|
item_parent_id,
|
|
)
|
|
)
|
|
|
|
async_add_entities(entity_list)
|
|
|
|
|
|
class Control4Climate(Control4Entity, ClimateEntity):
|
|
"""Control4 climate entity."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
|
_attr_supported_features = (
|
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
|
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
|
| ClimateEntityFeature.TURN_ON
|
|
| ClimateEntityFeature.TURN_OFF
|
|
)
|
|
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL]
|
|
|
|
def __init__(
|
|
self,
|
|
runtime_data: Control4RuntimeData,
|
|
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
|
|
name: str,
|
|
idx: int,
|
|
device_name: str | None,
|
|
device_manufacturer: str | None,
|
|
device_model: str | None,
|
|
device_id: int,
|
|
) -> None:
|
|
"""Initialize Control4 climate entity."""
|
|
super().__init__(
|
|
runtime_data,
|
|
coordinator,
|
|
name,
|
|
idx,
|
|
device_name,
|
|
device_manufacturer,
|
|
device_model,
|
|
device_id,
|
|
)
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Return if entity is available."""
|
|
return super().available and self._thermostat_data is not None
|
|
|
|
def _create_api_object(self) -> C4Climate:
|
|
"""Create a pyControl4 device object.
|
|
|
|
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
|
|
"""
|
|
return C4Climate(self.runtime_data.director, self._idx)
|
|
|
|
@property
|
|
def _thermostat_data(self) -> dict[str, Any] | None:
|
|
"""Return the thermostat data from the coordinator."""
|
|
return self.coordinator.data.get(self._idx)
|
|
|
|
@property
|
|
def current_temperature(self) -> float | None:
|
|
"""Return the current temperature."""
|
|
data = self._thermostat_data
|
|
if data is None:
|
|
return None
|
|
return data.get(CONTROL4_CURRENT_TEMPERATURE)
|
|
|
|
@property
|
|
def current_humidity(self) -> int | None:
|
|
"""Return the current humidity."""
|
|
data = self._thermostat_data
|
|
if data is None:
|
|
return None
|
|
humidity = data.get(CONTROL4_HUMIDITY)
|
|
return int(humidity) if humidity is not None else None
|
|
|
|
@property
|
|
def hvac_mode(self) -> HVACMode:
|
|
"""Return current HVAC mode."""
|
|
data = self._thermostat_data
|
|
if data is None:
|
|
return HVACMode.OFF
|
|
c4_mode = data.get(CONTROL4_HVAC_MODE) or ""
|
|
return C4_TO_HA_HVAC_MODE.get(c4_mode, HVACMode.OFF)
|
|
|
|
@property
|
|
def hvac_action(self) -> HVACAction | None:
|
|
"""Return current HVAC action."""
|
|
data = self._thermostat_data
|
|
if data is None:
|
|
return None
|
|
c4_state = data.get(CONTROL4_HVAC_STATE)
|
|
if c4_state is None:
|
|
return None
|
|
# Convert state to lowercase for mapping
|
|
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
|
|
|
@property
|
|
def target_temperature(self) -> float | None:
|
|
"""Return the target temperature."""
|
|
data = self._thermostat_data
|
|
if data is None:
|
|
return None
|
|
hvac_mode = self.hvac_mode
|
|
if hvac_mode == HVACMode.COOL:
|
|
return data.get(CONTROL4_COOL_SETPOINT)
|
|
if hvac_mode == HVACMode.HEAT:
|
|
return data.get(CONTROL4_HEAT_SETPOINT)
|
|
return None
|
|
|
|
@property
|
|
def target_temperature_high(self) -> float | None:
|
|
"""Return the high target temperature for auto mode."""
|
|
data = self._thermostat_data
|
|
if data is None:
|
|
return None
|
|
if self.hvac_mode == HVACMode.HEAT_COOL:
|
|
return data.get(CONTROL4_COOL_SETPOINT)
|
|
return None
|
|
|
|
@property
|
|
def target_temperature_low(self) -> float | None:
|
|
"""Return the low target temperature for auto mode."""
|
|
data = self._thermostat_data
|
|
if data is None:
|
|
return None
|
|
if self.hvac_mode == HVACMode.HEAT_COOL:
|
|
return data.get(CONTROL4_HEAT_SETPOINT)
|
|
return None
|
|
|
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
|
"""Set new target HVAC mode."""
|
|
c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode]
|
|
c4_climate = self._create_api_object()
|
|
await c4_climate.setHvacMode(c4_hvac_mode)
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
|
"""Set new target temperature."""
|
|
c4_climate = self._create_api_object()
|
|
low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
|
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
|
temp = kwargs.get(ATTR_TEMPERATURE)
|
|
|
|
# Handle temperature range for auto mode
|
|
if self.hvac_mode == HVACMode.HEAT_COOL:
|
|
if low_temp is not None:
|
|
await c4_climate.setHeatSetpointF(low_temp)
|
|
if high_temp is not None:
|
|
await c4_climate.setCoolSetpointF(high_temp)
|
|
# Handle single temperature setpoint
|
|
elif temp is not None:
|
|
if self.hvac_mode == HVACMode.COOL:
|
|
await c4_climate.setCoolSetpointF(temp)
|
|
elif self.hvac_mode == HVACMode.HEAT:
|
|
await c4_climate.setHeatSetpointF(temp)
|
|
|
|
await self.coordinator.async_request_refresh()
|