Add touchlinesl integration (#124557)

* touchlinesl: init integration

* integration(touchlinesl): address review feedback

* integration(touchlinesl): address review feedback

* integration(touchlinesl): add a coordinator to manage data updates

* integration(touchlinesl): address review feedback

* integration(touchline_sl): address feedback (and rename)

* integration(touchline_sl): address feedback

* integration(touchline_sl): address feedback

* integration(touchline_sl): update strings

* integration(touchline_sl): address feedback

* integration(touchline_sl): address feedback
This commit is contained in:
Jon Seager 2024-08-27 12:15:31 +01:00 committed by GitHub
parent 37e2839fa3
commit 9119884e53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 565 additions and 6 deletions

View file

@ -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

View file

@ -0,0 +1,5 @@
{
"domain": "roth",
"name": "Roth",
"integrations": ["touchline", "touchline_sl"]
}

View file

@ -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)

View file

@ -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

View file

@ -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
)

View file

@ -0,0 +1,3 @@
"""Constants for the Roth Touchline SL integration."""
DOMAIN = "touchline_sl"

View file

@ -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},
)

View file

@ -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"]
}

View file

@ -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"
}
}
}
}
}
}
}

View file

@ -594,6 +594,7 @@ FLOWS = {
"tomorrowio",
"toon",
"totalconnect",
"touchline_sl",
"tplink",
"tplink_omada",
"traccar",

View file

@ -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": {

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the Roth Touchline SL integration."""

View file

@ -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",
)

View file

@ -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"