diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index fda4c6e07dbf..4fb9d48490be 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -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, diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 57b29fa36571..7dbfb1748a89 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -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() diff --git a/homeassistant/components/tado/icons.json b/homeassistant/components/tado/icons.json index 488fe6b6c878..8b6cf227eec0 100644 --- a/homeassistant/components/tado/icons.json +++ b/homeassistant/components/tado/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "heating_circuit": { + "default": "mdi:water-boiler" + } + }, "switch": { "child_lock": { "default": "mdi:lock-open-variant", diff --git a/homeassistant/components/tado/select.py b/homeassistant/components/tado/select.py new file mode 100644 index 000000000000..554a74fe5cf7 --- /dev/null +++ b/homeassistant/components/tado/select.py @@ -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 diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index c9a931d84128..afad7d57bd02 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -49,6 +49,14 @@ } } }, + "select": { + "heating_circuit": { + "name": "Heating circuit", + "state": { + "no_heating_circuit": "No circuit" + } + } + }, "sensor": { "ac": { "name": "AC" diff --git a/tests/components/tado/__init__.py b/tests/components/tado/__init__.py index e6b6257e6ea6..62942e45b86f 100644 --- a/tests/components/tado/__init__.py +++ b/tests/components/tado/__init__.py @@ -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() diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py index 1c8d0214a0bb..1a629150fcb3 100644 --- a/tests/components/tado/conftest.py +++ b/tests/components/tado/conftest.py @@ -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), diff --git a/tests/components/tado/fixtures/heating_circuits.json b/tests/components/tado/fixtures/heating_circuits.json new file mode 100644 index 000000000000..5b1d48a90f00 --- /dev/null +++ b/tests/components/tado/fixtures/heating_circuits.json @@ -0,0 +1,12 @@ +[ + { + "number": 1, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234" + }, + { + "number": 2, + "driverSerialNo": "RU0987654321", + "driverShortSerialNo": "RU5678" + } +] diff --git a/tests/components/tado/fixtures/zone_control.json b/tests/components/tado/fixtures/zone_control.json new file mode 100644 index 000000000000..34b81651e2fe --- /dev/null +++ b/tests/components/tado/fixtures/zone_control.json @@ -0,0 +1,13 @@ +{ + "type": "HEATING", + "heatingCircuit": 1, + "earlyStartEnabled": false, + "duties": { + "type": "HEATING", + "leader": { + "deviceType": "RU02", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234" + } + } +} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index 19745cf103b6..cd616806569a 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -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, diff --git a/tests/components/tado/snapshots/test_select.ambr b/tests/components/tado/snapshots/test_select.ambr new file mode 100644 index 000000000000..2fd6af925b96 --- /dev/null +++ b/tests/components/tado/snapshots/test_select.ambr @@ -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({ + : list([ + 'no_heating_circuit', + 'RU1234', + 'RU5678', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.baseboard_heater_baseboard_heater_heating_circuit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'Baseboard Heater Heating circuit', + : list([ + 'no_heating_circuit', + 'RU1234', + 'RU5678', + ]), + }), + 'context': , + 'entity_id': 'select.baseboard_heater_baseboard_heater_heating_circuit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'RU1234', + }) +# --- diff --git a/tests/components/tado/test_select.py b/tests/components/tado/test_select.py new file mode 100644 index 000000000000..eb8428955b05 --- /dev/null +++ b/tests/components/tado/test_select.py @@ -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