Skip to content

Commit

Permalink
Add touchlinesl integration (home-assistant#124557)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jnsgruk authored Aug 27, 2024
1 parent 37e2839 commit 9119884
Show file tree
Hide file tree
Showing 16 changed files with 565 additions and 6 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/brands/roth.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"domain": "roth",
"name": "Roth",
"integrations": ["touchline", "touchline_sl"]
}
63 changes: 63 additions & 0 deletions homeassistant/components/touchline_sl/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
126 changes: 126 additions & 0 deletions homeassistant/components/touchline_sl/climate.py
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions homeassistant/components/touchline_sl/config_flow.py
Original file line number Diff line number Diff line change
@@ -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
)
3 changes: 3 additions & 0 deletions homeassistant/components/touchline_sl/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Roth Touchline SL integration."""

DOMAIN = "touchline_sl"
59 changes: 59 additions & 0 deletions homeassistant/components/touchline_sl/coordinator.py
Original file line number Diff line number Diff line change
@@ -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},
)
10 changes: 10 additions & 0 deletions homeassistant/components/touchline_sl/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
36 changes: 36 additions & 0 deletions homeassistant/components/touchline_sl/strings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@
"tomorrowio",
"toon",
"totalconnect",
"touchline_sl",
"tplink",
"tplink_omada",
"traccar",
Expand Down
Loading

0 comments on commit 9119884

Please sign in to comment.