mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
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:
parent
37e2839fa3
commit
9119884e53
@ -1493,6 +1493,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/tomorrowio/ @raman325 @lymanepp
|
/tests/components/tomorrowio/ @raman325 @lymanepp
|
||||||
/homeassistant/components/totalconnect/ @austinmroczek
|
/homeassistant/components/totalconnect/ @austinmroczek
|
||||||
/tests/components/totalconnect/ @austinmroczek
|
/tests/components/totalconnect/ @austinmroczek
|
||||||
|
/homeassistant/components/touchline_sl/ @jnsgruk
|
||||||
|
/tests/components/touchline_sl/ @jnsgruk
|
||||||
/homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696
|
/homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696
|
||||||
/tests/components/tplink/ @rytilahti @bdraco @sdb9696
|
/tests/components/tplink/ @rytilahti @bdraco @sdb9696
|
||||||
/homeassistant/components/tplink_omada/ @MarkGodwin
|
/homeassistant/components/tplink_omada/ @MarkGodwin
|
||||||
|
5
homeassistant/brands/roth.json
Normal file
5
homeassistant/brands/roth.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "roth",
|
||||||
|
"name": "Roth",
|
||||||
|
"integrations": ["touchline", "touchline_sl"]
|
||||||
|
}
|
63
homeassistant/components/touchline_sl/__init__.py
Normal file
63
homeassistant/components/touchline_sl/__init__.py
Normal 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)
|
126
homeassistant/components/touchline_sl/climate.py
Normal file
126
homeassistant/components/touchline_sl/climate.py
Normal 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
|
62
homeassistant/components/touchline_sl/config_flow.py
Normal file
62
homeassistant/components/touchline_sl/config_flow.py
Normal 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
|
||||||
|
)
|
3
homeassistant/components/touchline_sl/const.py
Normal file
3
homeassistant/components/touchline_sl/const.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Constants for the Roth Touchline SL integration."""
|
||||||
|
|
||||||
|
DOMAIN = "touchline_sl"
|
59
homeassistant/components/touchline_sl/coordinator.py
Normal file
59
homeassistant/components/touchline_sl/coordinator.py
Normal 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},
|
||||||
|
)
|
10
homeassistant/components/touchline_sl/manifest.json
Normal file
10
homeassistant/components/touchline_sl/manifest.json
Normal 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"]
|
||||||
|
}
|
36
homeassistant/components/touchline_sl/strings.json
Normal file
36
homeassistant/components/touchline_sl/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -594,6 +594,7 @@ FLOWS = {
|
|||||||
"tomorrowio",
|
"tomorrowio",
|
||||||
"toon",
|
"toon",
|
||||||
"totalconnect",
|
"totalconnect",
|
||||||
|
"touchline_sl",
|
||||||
"tplink",
|
"tplink",
|
||||||
"tplink_omada",
|
"tplink_omada",
|
||||||
"traccar",
|
"traccar",
|
||||||
|
@ -5134,6 +5134,23 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_push"
|
"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": {
|
"rova": {
|
||||||
"name": "ROVA",
|
"name": "ROVA",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
@ -6297,12 +6314,6 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "cloud_polling"
|
||||||
},
|
},
|
||||||
"touchline": {
|
|
||||||
"name": "Roth Touchline",
|
|
||||||
"integration_type": "hub",
|
|
||||||
"config_flow": false,
|
|
||||||
"iot_class": "local_polling"
|
|
||||||
},
|
|
||||||
"tplink": {
|
"tplink": {
|
||||||
"name": "TP-Link",
|
"name": "TP-Link",
|
||||||
"integrations": {
|
"integrations": {
|
||||||
|
@ -2385,6 +2385,9 @@ pytomorrowio==0.3.6
|
|||||||
# homeassistant.components.touchline
|
# homeassistant.components.touchline
|
||||||
pytouchline==0.7
|
pytouchline==0.7
|
||||||
|
|
||||||
|
# homeassistant.components.touchline_sl
|
||||||
|
pytouchlinesl==0.1.5
|
||||||
|
|
||||||
# homeassistant.components.traccar
|
# homeassistant.components.traccar
|
||||||
# homeassistant.components.traccar_server
|
# homeassistant.components.traccar_server
|
||||||
pytraccar==2.1.1
|
pytraccar==2.1.1
|
||||||
|
@ -1888,6 +1888,9 @@ pytile==2023.12.0
|
|||||||
# homeassistant.components.tomorrowio
|
# homeassistant.components.tomorrowio
|
||||||
pytomorrowio==0.3.6
|
pytomorrowio==0.3.6
|
||||||
|
|
||||||
|
# homeassistant.components.touchline_sl
|
||||||
|
pytouchlinesl==0.1.5
|
||||||
|
|
||||||
# homeassistant.components.traccar
|
# homeassistant.components.traccar
|
||||||
# homeassistant.components.traccar_server
|
# homeassistant.components.traccar_server
|
||||||
pytraccar==2.1.1
|
pytraccar==2.1.1
|
||||||
|
1
tests/components/touchline_sl/__init__.py
Normal file
1
tests/components/touchline_sl/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Roth Touchline SL integration."""
|
61
tests/components/touchline_sl/conftest.py
Normal file
61
tests/components/touchline_sl/conftest.py
Normal 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",
|
||||||
|
)
|
113
tests/components/touchline_sl/test_config_flow.py
Normal file
113
tests/components/touchline_sl/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user