diff --git a/CODEOWNERS b/CODEOWNERS index 1618b18a8be53a..20e0b3adced92f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1493,6 +1493,8 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek +/homeassistant/components/touchline_sl/ @jnsgruk +/tests/components/touchline_sl/ @jnsgruk /homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696 /tests/components/tplink/ @rytilahti @bdraco @sdb9696 /homeassistant/components/tplink_omada/ @MarkGodwin diff --git a/homeassistant/brands/roth.json b/homeassistant/brands/roth.json new file mode 100644 index 00000000000000..21542b5b64151b --- /dev/null +++ b/homeassistant/brands/roth.json @@ -0,0 +1,5 @@ +{ + "domain": "roth", + "name": "Roth", + "integrations": ["touchline", "touchline_sl"] +} diff --git a/homeassistant/components/touchline_sl/__init__.py b/homeassistant/components/touchline_sl/__init__.py new file mode 100644 index 00000000000000..45a851856731e5 --- /dev/null +++ b/homeassistant/components/touchline_sl/__init__.py @@ -0,0 +1,63 @@ +"""The Roth Touchline SL integration.""" + +from __future__ import annotations + +import asyncio + +from pytouchlinesl import TouchlineSL + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import TouchlineSLModuleCoordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + +type TouchlineSLConfigEntry = ConfigEntry[list[TouchlineSLModuleCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) -> bool: + """Set up Roth Touchline SL from a config entry.""" + account = TouchlineSL( + username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD] + ) + + coordinators: list[TouchlineSLModuleCoordinator] = [ + TouchlineSLModuleCoordinator(hass, module) for module in await account.modules() + ] + + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ] + ) + + device_registry = dr.async_get(hass) + + # Create a new Device for each coorodinator to represent each module + for c in coordinators: + module = c.data.module + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, module.id)}, + name=module.name, + manufacturer="Roth", + model=module.type, + sw_version=module.version, + ) + + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: TouchlineSLConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py new file mode 100644 index 00000000000000..93328823749b99 --- /dev/null +++ b/homeassistant/components/touchline_sl/climate.py @@ -0,0 +1,126 @@ +"""Roth Touchline SL climate integration implementation for Home Assistant.""" + +from typing import Any + +from pytouchlinesl import Zone + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import TouchlineSLConfigEntry +from .const import DOMAIN +from .coordinator import TouchlineSLModuleCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TouchlineSLConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Touchline devices.""" + coordinators = entry.runtime_data + async_add_entities( + TouchlineSLZone(coordinator=coordinator, zone_id=zone_id) + for coordinator in coordinators + for zone_id in coordinator.data.zones + ) + + +CONSTANT_TEMPERATURE = "constant_temperature" + + +class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEntity): + """Roth Touchline SL Zone.""" + + _attr_has_entity_name = True + _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_modes = [HVACMode.HEAT] + _attr_name = None + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "zone" + + def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None: + """Construct a Touchline SL climate zone.""" + super().__init__(coordinator) + self.zone_id: int = zone_id + + self._attr_unique_id = ( + f"module-{self.coordinator.data.module.id}-zone-{self.zone_id}" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(zone_id))}, + name=self.zone.name, + manufacturer="Roth", + via_device=(DOMAIN, coordinator.data.module.id), + model="zone", + suggested_area=self.zone.name, + ) + + # Call this in __init__ so data is populated right away, since it's + # already available in the coordinator data. + self.set_attr() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.set_attr() + super()._handle_coordinator_update() + + @property + def zone(self) -> Zone: + """Return the device object from the coordinator data.""" + return self.coordinator.data.zones[self.zone_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.zone_id in self.coordinator.data.zones + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.zone.set_temperature(temperature) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Assign the zone to a particular global schedule.""" + if not self.zone: + return + + if preset_mode == CONSTANT_TEMPERATURE and self._attr_target_temperature: + await self.zone.set_temperature(temperature=self._attr_target_temperature) + await self.coordinator.async_request_refresh() + return + + if schedule := self.coordinator.data.schedules[preset_mode]: + await self.zone.set_schedule(schedule_id=schedule.id) + await self.coordinator.async_request_refresh() + + def set_attr(self) -> None: + """Populate attributes with data from the coordinator.""" + schedule_names = self.coordinator.data.schedules.keys() + + self._attr_current_temperature = self.zone.temperature + self._attr_target_temperature = self.zone.target_temperature + self._attr_current_humidity = int(self.zone.humidity) + self._attr_preset_modes = [*schedule_names, CONSTANT_TEMPERATURE] + + if self.zone.mode == "constantTemp": + self._attr_preset_mode = CONSTANT_TEMPERATURE + elif self.zone.mode == "globalSchedule": + schedule = self.zone.schedule + self._attr_preset_mode = schedule.name diff --git a/homeassistant/components/touchline_sl/config_flow.py b/homeassistant/components/touchline_sl/config_flow.py new file mode 100644 index 00000000000000..91d959b5a0a9a4 --- /dev/null +++ b/homeassistant/components/touchline_sl/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Roth Touchline SL integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pytouchlinesl import TouchlineSL +from pytouchlinesl.client import RothAPIError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class TouchlineSLConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Roth Touchline SL.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step that gathers username and password.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + account = TouchlineSL( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + await account.user_id() + except RothAPIError as e: + if e.status == 401: + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + unique_account_id = await account.user_id() + await self.async_set_unique_id(str(unique_account_id)) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/touchline_sl/const.py b/homeassistant/components/touchline_sl/const.py new file mode 100644 index 00000000000000..e441e3721b378f --- /dev/null +++ b/homeassistant/components/touchline_sl/const.py @@ -0,0 +1,3 @@ +"""Constants for the Roth Touchline SL integration.""" + +DOMAIN = "touchline_sl" diff --git a/homeassistant/components/touchline_sl/coordinator.py b/homeassistant/components/touchline_sl/coordinator.py new file mode 100644 index 00000000000000..cd74ba6130f032 --- /dev/null +++ b/homeassistant/components/touchline_sl/coordinator.py @@ -0,0 +1,59 @@ +"""Define an object to manage fetching Touchline SL data.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pytouchlinesl import Module, Zone +from pytouchlinesl.client import RothAPIError +from pytouchlinesl.client.models import GlobalScheduleModel + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class TouchlineSLModuleData: + """Provide type safe way of accessing module data from the coordinator.""" + + module: Module + zones: dict[int, Zone] + schedules: dict[str, GlobalScheduleModel] + + +class TouchlineSLModuleCoordinator(DataUpdateCoordinator[TouchlineSLModuleData]): + """A coordinator to manage the fetching of Touchline SL data.""" + + def __init__(self, hass: HomeAssistant, module: Module) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=f"Touchline SL ({module.name})", + update_interval=timedelta(seconds=30), + ) + + self.module = module + + async def _async_update_data(self) -> TouchlineSLModuleData: + """Fetch data from the upstream API and pre-process into the right format.""" + try: + zones = await self.module.zones() + schedules = await self.module.schedules() + except RothAPIError as error: + if error.status == 401: + # Trigger a reauthentication if the data update fails due to + # bad authentication. + raise ConfigEntryAuthFailed from error + raise UpdateFailed(error) from error + + return TouchlineSLModuleData( + module=self.module, + zones={z.id: z for z in zones}, + schedules={s.name: s for s in schedules}, + ) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json new file mode 100644 index 00000000000000..8a50b06d613e10 --- /dev/null +++ b/homeassistant/components/touchline_sl/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "touchline_sl", + "name": "Roth Touchline SL", + "codeowners": ["@jnsgruk"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/touchline_sl", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["pytouchlinesl==0.1.5"] +} diff --git a/homeassistant/components/touchline_sl/strings.json b/homeassistant/components/touchline_sl/strings.json new file mode 100644 index 00000000000000..e3a0ef5a741914 --- /dev/null +++ b/homeassistant/components/touchline_sl/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "flow_title": "Touchline SL Setup Flow", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "title": "Login to Touchline SL", + "description": "Your credentials for the Roth Touchline SL mobile app/web service", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "climate": { + "zone": { + "state_attributes": { + "preset_mode": { + "state": { + "constant_temperature": "Constant temperature" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5e6d29f29f9701..acf0439e842863 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -594,6 +594,7 @@ "tomorrowio", "toon", "totalconnect", + "touchline_sl", "tplink", "tplink_omada", "traccar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 52215d232ade23..d6ce9e0a3aa731 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5134,6 +5134,23 @@ "config_flow": true, "iot_class": "local_push" }, + "roth": { + "name": "Roth", + "integrations": { + "touchline": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Roth Touchline" + }, + "touchline_sl": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Roth Touchline SL" + } + } + }, "rova": { "name": "ROVA", "integration_type": "hub", @@ -6297,12 +6314,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "touchline": { - "name": "Roth Touchline", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "tplink": { "name": "TP-Link", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index f8d7b908ec3cb9..6a833b546bd488 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2385,6 +2385,9 @@ pytomorrowio==0.3.6 # homeassistant.components.touchline pytouchline==0.7 +# homeassistant.components.touchline_sl +pytouchlinesl==0.1.5 + # homeassistant.components.traccar # homeassistant.components.traccar_server pytraccar==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0307433e514e67..93005222ffd956 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1888,6 +1888,9 @@ pytile==2023.12.0 # homeassistant.components.tomorrowio pytomorrowio==0.3.6 +# homeassistant.components.touchline_sl +pytouchlinesl==0.1.5 + # homeassistant.components.traccar # homeassistant.components.traccar_server pytraccar==2.1.1 diff --git a/tests/components/touchline_sl/__init__.py b/tests/components/touchline_sl/__init__.py new file mode 100644 index 00000000000000..c22e9d329db364 --- /dev/null +++ b/tests/components/touchline_sl/__init__.py @@ -0,0 +1 @@ +"""Tests for the Roth Touchline SL integration.""" diff --git a/tests/components/touchline_sl/conftest.py b/tests/components/touchline_sl/conftest.py new file mode 100644 index 00000000000000..4edeb048f5bd48 --- /dev/null +++ b/tests/components/touchline_sl/conftest.py @@ -0,0 +1,61 @@ +"""Common fixtures for the Roth Touchline SL tests.""" + +from collections.abc import Generator +from typing import NamedTuple +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.touchline_sl.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +class FakeModule(NamedTuple): + """Fake Module used for unit testing only.""" + + name: str + id: str + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.touchline_sl.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_touchlinesl_client() -> Generator[AsyncMock]: + """Mock a pytouchlinesl client.""" + with ( + patch( + "homeassistant.components.touchline_sl.TouchlineSL", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.touchline_sl.config_flow.TouchlineSL", + new=mock_client, + ), + ): + client = mock_client.return_value + client.user_id.return_value = 12345 + client.modules.return_value = [FakeModule(name="Foobar", id="deadbeef")] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="TouchlineSL", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + unique_id="12345", + ) diff --git a/tests/components/touchline_sl/test_config_flow.py b/tests/components/touchline_sl/test_config_flow.py new file mode 100644 index 00000000000000..992fa2bdb3e86b --- /dev/null +++ b/tests/components/touchline_sl/test_config_flow.py @@ -0,0 +1,113 @@ +"""Test the Roth Touchline SL config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from pytouchlinesl.client import RothAPIError + +from homeassistant.components.touchline_sl.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +RESULT_UNIQUE_ID = "12345" + +CONFIG_DATA = { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +async def test_config_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_touchlinesl_client: AsyncMock +) -> None: + """Test the happy path where the provided username/password result in a new entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_DATA + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == CONFIG_DATA + assert result["result"].unique_id == RESULT_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_base"), + [ + (RothAPIError(status=401), "invalid_auth"), + (RothAPIError(status=502), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_config_flow_failure_api_exceptions( + hass: HomeAssistant, + exception: Exception, + error_base: str, + mock_setup_entry: AsyncMock, + mock_touchlinesl_client: AsyncMock, +) -> None: + """Test for invalid credentials or API connection errors, and that the form can recover.""" + mock_touchlinesl_client.user_id.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_DATA + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_base} + + # "Fix" the problem, and try again. + mock_touchlinesl_client.user_id.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_DATA + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == CONFIG_DATA + assert result["result"].unique_id == RESULT_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_failure_adding_non_unique_account( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_touchlinesl_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the config flow fails when user tries to add duplicate accounts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_DATA + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"