forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add touchlinesl integration (home-assistant#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
- Loading branch information
Showing
16 changed files
with
565 additions
and
6 deletions.
There are no files selected for viewing
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"domain": "roth", | ||
"name": "Roth", | ||
"integrations": ["touchline", "touchline_sl"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
"""Constants for the Roth Touchline SL integration.""" | ||
|
||
DOMAIN = "touchline_sl" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -594,6 +594,7 @@ | |
"tomorrowio", | ||
"toon", | ||
"totalconnect", | ||
"touchline_sl", | ||
"tplink", | ||
"tplink_omada", | ||
"traccar", | ||
|
Oops, something went wrong.