Add heating circuit select to Tado zones (#178096)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
This commit is contained in:
PeterLinuxOS
2026-08-17 19:31:40 +02:00
committed by GitHub
co-authored by Claude Erwin Douna
parent 8ebff76eff
commit 7555486558
12 changed files with 443 additions and 0 deletions
@@ -39,6 +39,7 @@ from .services import async_setup_services
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
@@ -24,6 +24,7 @@ from .const import (
INSIDE_TEMPERATURE_MEASUREMENT,
PRESET_AUTO,
TEMP_OFFSET,
TYPE_HEATING,
)
_LOGGER = logging.getLogger(__name__)
@@ -68,11 +69,14 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.home_name: str
self.zones: list[dict[Any, Any]] = []
self.devices: list[dict[Any, Any]] = []
self.heating_circuits: dict[str, dict[str, Any]] = {}
self._heating_circuits_loaded = False
self.data: dict[str, Any] = {
"device": {},
"weather": {},
"geofence": {},
"zone": {},
"zone_control": {},
}
self._current_interval: float = 0
@@ -114,6 +118,11 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.home_id = tado_home["id"]
self.home_name = tado_home["name"]
# Heating circuits are configuration, so fetching them once is enough.
if not self._heating_circuits_loaded:
await self._async_fetch_heating_circuits()
self._heating_circuits_loaded = True
devices = await self._async_update_devices()
zones = await self._async_update_zones()
home = await self._async_update_home()
@@ -471,6 +480,43 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except RequestException as exc:
raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc
async def _async_fetch_heating_circuits(self) -> None:
"""Fetch the heating circuits and their current per-zone assignment."""
heating_zones = [zone for zone in self.zones if zone["type"] == TYPE_HEATING]
if not heating_zones:
return
def _load_circuits() -> tuple[list[dict[str, Any]], dict[int, dict[str, Any]]]:
return self._tado.get_heating_circuits(), {
zone["id"]: self._tado.get_zone_control(zone["id"])
for zone in heating_zones
}
try:
circuits, controls = await self.hass.async_add_executor_job(_load_circuits)
except RequestException as err:
raise UpdateFailed(f"Error updating Tado heating circuits: {err}") from err
self.heating_circuits = {
circuit["driverShortSerialNo"]: circuit for circuit in circuits
}
self.data["zone_control"] = controls
async def set_heating_circuit(
self, zone_id: int, circuit_number: int | None
) -> None:
"""Assign a heating circuit to a zone, or clear it when None."""
try:
await self.hass.async_add_executor_job(
self._tado.set_zone_heating_circuit, zone_id, circuit_number
)
except RequestException as err:
raise HomeAssistantError(
f"Error setting Tado heating circuit for zone {zone_id}: {err}"
) from err
self.data["zone_control"][zone_id]["heatingCircuit"] = circuit_number
def get_rate_limit(self) -> dict[str, str]:
"""Get the current rate limit status from Tado."""
return self._tado.rate_limit_info()
+5
View File
@@ -1,5 +1,10 @@
{
"entity": {
"select": {
"heating_circuit": {
"default": "mdi:water-boiler"
}
},
"switch": {
"child_lock": {
"default": "mdi:lock-open-variant",
+100
View File
@@ -0,0 +1,100 @@
"""Select platform for the Tado integration."""
import logging
from typing import override
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TYPE_HEATING
from .coordinator import TadoConfigEntry, TadoDataUpdateCoordinator
from .entity import TadoZoneEntity
_LOGGER = logging.getLogger(__name__)
NO_HEATING_CIRCUIT_OPTION = "no_heating_circuit"
async def async_setup_entry(
hass: HomeAssistant,
entry: TadoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tado select platform."""
coordinator = entry.runtime_data
if not coordinator.heating_circuits:
return
async_add_entities(
TadoHeatingCircuitSelectEntity(coordinator, zone["name"], zone["id"])
for zone in coordinator.zones
if zone["type"] == TYPE_HEATING
)
class TadoHeatingCircuitSelectEntity(TadoZoneEntity, SelectEntity):
"""Representation of the heating circuit assigned to a Tado zone."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "heating_circuit"
def __init__(
self,
coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
) -> None:
"""Initialize the Tado heating circuit select entity."""
super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
self._attr_unique_id = f"{zone_id} {coordinator.home_id} heating_circuit"
self._attr_options = [
NO_HEATING_CIRCUIT_OPTION,
*coordinator.heating_circuits,
]
self._async_update_callback()
@override
async def async_select_option(self, option: str) -> None:
"""Assign the selected heating circuit to this zone."""
circuit_number = None
if option != NO_HEATING_CIRCUIT_OPTION:
circuit_number = self.coordinator.heating_circuits[option]["number"]
await self.coordinator.set_heating_circuit(self.zone_id, circuit_number)
self._async_update_callback()
self.async_write_ha_state()
@callback
@override
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_callback()
super()._handle_coordinator_update()
@callback
def _async_update_callback(self) -> None:
"""Resolve the circuit number currently assigned to this zone."""
circuit_number = (
self.coordinator.data["zone_control"]
.get(self.zone_id, {})
.get("heatingCircuit")
)
if circuit_number is None:
self._attr_current_option = NO_HEATING_CIRCUIT_OPTION
return
for serial, circuit in self.coordinator.heating_circuits.items():
if circuit["number"] == circuit_number:
self._attr_current_option = serial
return
_LOGGER.debug(
"Heating circuit %s of zone %s is not in the list of circuits",
circuit_number,
self.zone_name,
)
self._attr_current_option = None
@@ -49,6 +49,14 @@
}
}
},
"select": {
"heating_circuit": {
"name": "Heating circuit",
"state": {
"no_heating_circuit": "No circuit"
}
}
},
"sensor": {
"ac": {
"name": "AC"
+11
View File
@@ -1 +1,12 @@
"""Tests for the Tado integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the Tado integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
+8
View File
@@ -154,6 +154,14 @@ async def init_integration(hass: HomeAssistant):
"https://my.tado.com/api/v2/devices/WR4/temperatureOffset",
text=await async_load_fixture(hass, device_temp_offset, DOMAIN),
)
m.get(
"https://my.tado.com/api/v2/homes/1/heatingCircuits",
text=await async_load_fixture(hass, "heating_circuits.json", DOMAIN),
)
m.get(
"https://my.tado.com/api/v2/homes/1/zones/1/control",
text=await async_load_fixture(hass, "zone_control.json", DOMAIN),
)
m.get(
"https://my.tado.com/api/v2/homes/1/zones",
text=await async_load_fixture(hass, zones_fixture, DOMAIN),
@@ -0,0 +1,12 @@
[
{
"number": 1,
"driverSerialNo": "RU1234567890",
"driverShortSerialNo": "RU1234"
},
{
"number": 2,
"driverSerialNo": "RU0987654321",
"driverShortSerialNo": "RU5678"
}
]
@@ -0,0 +1,13 @@
{
"type": "HEATING",
"heatingCircuit": 1,
"earlyStartEnabled": false,
"duties": {
"type": "HEATING",
"leader": {
"deviceType": "RU02",
"serialNo": "RU1234567890",
"shortSerialNo": "RU1234"
}
}
}
@@ -128,6 +128,21 @@
'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)",
}),
}),
'zone_control': dict({
'1': dict({
'duties': dict({
'leader': dict({
'deviceType': 'RU02',
'serialNo': 'RU1234567890',
'shortSerialNo': 'RU1234',
}),
'type': 'HEATING',
}),
'earlyStartEnabled': False,
'heatingCircuit': 1,
'type': 'HEATING',
}),
}),
}),
'rate_limit': dict({
'per-day': 1000,
@@ -0,0 +1,62 @@
# serializer version: 1
# name: test_entities[select.baseboard_heater_baseboard_heater_heating_circuit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'no_heating_circuit',
'RU1234',
'RU5678',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.baseboard_heater_baseboard_heater_heating_circuit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Heating circuit',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Heating circuit',
'platform': 'tado',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'heating_circuit',
'unique_id': '1 1 heating_circuit',
'unit_of_measurement': None,
})
# ---
# name: test_entities[select.baseboard_heater_baseboard_heater_heating_circuit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Baseboard Heater Heating circuit',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'no_heating_circuit',
'RU1234',
'RU5678',
]),
}),
'context': <ANY>,
'entity_id': 'select.baseboard_heater_baseboard_heater_heating_circuit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'RU1234',
})
# ---
+162
View File
@@ -0,0 +1,162 @@
"""Test the Tado select platform."""
from collections.abc import Generator
from datetime import timedelta
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from requests import RequestException
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.select import (
ATTR_OPTION,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.components.tado import DOMAIN
from homeassistant.components.tado.const import TYPE_HEATING
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "select.baseboard_heater_baseboard_heater_heating_circuit"
@pytest.fixture(autouse=True)
def setup_platforms() -> Generator[None]:
"""Set up the platforms for the tests."""
with patch("homeassistant.components.tado.PLATFORMS", [Platform.SELECT]):
yield
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
"""Test creation of select entities."""
config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize(
("option", "expected"), [("RU5678", 2), ("no_heating_circuit", None)]
)
@pytest.mark.usefixtures("init_integration")
async def test_select_option(
hass: HomeAssistant, option: str, expected: int | None
) -> None:
"""Test selecting an option sends the circuit number to Tado."""
with patch(
"PyTado.interface.api.my_tado.Tado.set_zone_heating_circuit"
) as mock_set_circuit:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: option},
blocking=True,
)
mock_set_circuit.assert_called_once_with(1, expected)
assert hass.states.get(ENTITY_ID).state == option
@pytest.mark.usefixtures("init_integration")
async def test_select_option_error(hass: HomeAssistant) -> None:
"""Test a failing assignment is reported to the user."""
with (
patch(
"PyTado.interface.api.my_tado.Tado.set_zone_heating_circuit",
side_effect=RequestException("Boom"),
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: "RU5678"},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_configuration_is_read_once(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test a refresh does not read the heating circuit configuration again."""
coordinator = hass.config_entries.async_entries(DOMAIN)[0].runtime_data
with (
patch(
"PyTado.interface.api.my_tado.Tado.get_heating_circuits"
) as mock_circuits,
patch("PyTado.interface.api.my_tado.Tado.get_zone_control") as mock_control,
):
freezer.tick(coordinator.update_interval + timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_circuits.assert_not_called()
mock_control.assert_not_called()
@pytest.mark.usefixtures("init_integration")
async def test_failed_read_retries_the_setup(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test a failing heating circuit read makes the config entry retry."""
with patch(
"PyTado.interface.api.my_tado.Tado.get_heating_circuits",
side_effect=RequestException("Boom"),
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("init_integration")
async def test_no_circuits_read_without_heating_zones(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test homes without a heating zone do not read the circuits at all."""
zones = [
zone
for zone in hass.config_entries.async_entries(DOMAIN)[0].runtime_data.zones
if zone["type"] != TYPE_HEATING
]
with (
patch("PyTado.interface.api.my_tado.Tado.get_zones", return_value=zones),
patch(
"PyTado.interface.api.my_tado.Tado.get_heating_circuits"
) as mock_circuits,
):
await setup_integration(hass, mock_config_entry)
mock_circuits.assert_not_called()
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.usefixtures("init_integration")
async def test_unknown_when_circuit_is_not_listed(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test the state is unknown when the assigned circuit is not in the list."""
await hass.config_entries.async_remove(
hass.config_entries.async_entries(DOMAIN)[0].entry_id
)
with patch(
"PyTado.interface.api.my_tado.Tado.get_zone_control",
return_value={"type": "HEATING", "heatingCircuit": 99},
):
await setup_integration(hass, mock_config_entry)
assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN