Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,5 @@ pytest_buckets.txt

# AI tooling
.claude/settings.local.json
.serena/

2 changes: 1 addition & 1 deletion homeassistant/components/control4/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]


@dataclass
Expand Down
301 changes: 301 additions & 0 deletions homeassistant/components/control4/climate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
"""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()
2 changes: 1 addition & 1 deletion homeassistant/components/droplet/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/droplet",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pydroplet==2.3.3"],
"requirements": ["pydroplet==2.3.4"],
"zeroconf": ["_droplet._tcp.local."]
}
4 changes: 4 additions & 0 deletions homeassistant/components/hassio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"

PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
Expand All @@ -120,6 +122,7 @@
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"

CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
Expand Down Expand Up @@ -156,6 +159,7 @@
ISSUE_KEY_ADDON_PWNED: {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
}


Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/hassio/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
Expand Down Expand Up @@ -84,6 +85,7 @@
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED,
}

_LOGGER = logging.getLogger(__name__)
Expand Down
Loading
Loading