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
16 changed files with 565 additions and 6 deletions
|
@ -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
|
||||
|
|
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",
|
||||
"toon",
|
||||
"totalconnect",
|
||||
"touchline_sl",
|
||||
"tplink",
|
||||
"tplink_omada",
|
||||
"traccar",
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
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…
Add table
Reference in a new issue