mirror of
https://github.com/home-assistant/core.git
synced 2026-08-20 05:10:44 +01:00
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:
co-authored by
Claude
Erwin Douna
parent
8ebff76eff
commit
7555486558
@@ -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()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"heating_circuit": {
|
||||
"default": "mdi:water-boiler"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"child_lock": {
|
||||
"default": "mdi:lock-open-variant",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user