1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 15:44:52 +01:00

Add config flow to touchline integration (#165790)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Magnus Nordseth
2026-03-23 17:50:46 +01:00
committed by GitHub
parent 3d1a8fb08c
commit 18a6478d9a
13 changed files with 474 additions and 16 deletions

View File

@@ -1 +1,46 @@
"""The touchline component."""
from __future__ import annotations
import logging
from pytouchline_extended import PyTouchline
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .data import TouchlineConfigEntry, TouchlineData
PLATFORMS = [Platform.CLIMATE]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: TouchlineConfigEntry) -> bool:
"""Set up touchline from a config entry."""
host = entry.data[CONF_HOST]
_LOGGER.debug(
"Touchline entry id: %s Unique id: %s", entry.entry_id, entry.unique_id
)
py_touchline = PyTouchline(url=host)
try:
number_of_devices = int(
await hass.async_add_executor_job(py_touchline.get_number_of_devices)
)
except (OSError, ConnectionError, TimeoutError) as err:
raise ConfigEntryNotReady(
f"Error while connecting to Touchline controller at {host}"
) from err
entry.runtime_data = TouchlineData(
touchline=py_touchline, number_of_devices=number_of_devices
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a touchline config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -13,12 +13,20 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
from .data import TouchlineConfigEntry
class PresetMode(NamedTuple):
"""Settings for preset mode."""
@@ -44,22 +52,73 @@ TOUCHLINE_HA_PRESETS = {
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string})
def setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
entry: TouchlineConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Touchline devices."""
"""Set up Touchline devices from a config entry."""
host = entry.data[CONF_HOST]
host = config[CONF_HOST]
py_touchline = PyTouchline(url=host)
number_of_devices = int(py_touchline.get_number_of_devices())
devices = [
Touchline(PyTouchline(id=device_id, url=host))
for device_id in range(number_of_devices)
for device_id in range(entry.runtime_data.number_of_devices)
]
add_entities(devices, True)
async_add_entities(devices, True)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Touchline devices from YAML.
Touchline now uses config entries. If an entry exists in configuration.yaml,
the import flow will attempt to import it and create a config entry.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: config[CONF_HOST]},
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
breaks_in_ha_version="2026.10.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Roth Touchline",
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2026.10.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Roth Touchline",
},
)
class Touchline(ClimateEntity):

View File

@@ -0,0 +1,110 @@
"""Config flow for Roth Touchline integration."""
from __future__ import annotations
import logging
from typing import Any
from pytouchline_extended import PyTouchline
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)
def fetch_unique_id(host: str) -> str:
"""Fetch the unique id for the Touchline controller."""
client = PyTouchline(url=host)
client.get_number_of_devices()
client.update()
return str(client.get_controller_id())
async def _async_validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str:
"""Validate the user input allows us to connect."""
host = data[CONF_HOST]
try:
return await hass.async_add_executor_job(fetch_unique_id, host)
except (OSError, ConnectionError, TimeoutError) as err:
_LOGGER.debug(
"Error while connecting to Touchline controller at %s", host, exc_info=True
)
raise CannotConnect from err
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class TouchlineConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Roth Touchline."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try:
unique_id = await _async_validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML."""
# Abort if an entry with the same host already exists, to avoid duplicates
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
# Validate the user input allows us to connect
try:
unique_id = await _async_validate_input(self.hass, user_input)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001
return self.async_abort(reason="unknown")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_HOST],
data=user_input,
)

View File

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

View File

@@ -0,0 +1,19 @@
"""Custom types for Touchline."""
from __future__ import annotations
from dataclasses import dataclass
from pytouchline_extended import PyTouchline
from homeassistant.config_entries import ConfigEntry
type TouchlineConfigEntry = ConfigEntry[TouchlineData]
@dataclass
class TouchlineData:
"""Runtime data for the Touchline integration."""
touchline: PyTouchline
number_of_devices: int

View File

@@ -2,9 +2,10 @@
"domain": "touchline",
"name": "Roth Touchline",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/touchline",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pytouchline"],
"quality_scale": "legacy",
"loggers": ["pytouchline_extended"],
"requirements": ["pytouchline_extended==0.4.5"]
}

View File

@@ -0,0 +1,31 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Roth Touchline controller."
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Home Assistant could not connect to the Roth Touchline controller while importing your YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.",
"title": "Roth Touchline YAML configuration import failed"
},
"deprecated_yaml_import_issue_unknown": {
"description": "An unknown error occurred while importing your Roth Touchline YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.",
"title": "Roth Touchline YAML configuration import issue"
}
}
}

View File

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

View File

@@ -5870,7 +5870,7 @@
"integrations": {
"touchline": {
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling",
"name": "Roth Touchline"
},

View File

@@ -2291,6 +2291,9 @@ pytile==2024.12.0
# homeassistant.components.tomorrowio
pytomorrowio==0.3.6
# homeassistant.components.touchline
pytouchline_extended==0.4.5
# homeassistant.components.touchline_sl
pytouchlinesl==0.6.0

View File

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

View File

@@ -0,0 +1,28 @@
"""Fixtures for the Touchline config flow tests."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def mock_pytouchline() -> MagicMock:
"""Patch PyTouchline used by the config flow."""
with patch("homeassistant.components.touchline.config_flow.PyTouchline") as cls:
instance = cls.return_value
instance.get_number_of_devices.return_value = 1
instance.update.return_value = None
instance.get_controller_id.return_value = "controller-1"
yield instance
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.touchline.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@@ -0,0 +1,157 @@
"""Test the Touchline config flow."""
from __future__ import annotations
from unittest.mock import AsyncMock
from homeassistant import config_entries
from homeassistant.components.touchline.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_HOST = "1.2.3.4"
TEST_DATA = {CONF_HOST: TEST_HOST}
TEST_UNIQUE_ID = "controller-1"
async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.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"],
user_input=TEST_DATA,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_HOST
assert result["data"] == TEST_DATA
assert result["result"].unique_id == TEST_UNIQUE_ID
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(hass: HomeAssistant, mock_pytouchline) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
# The config flow runs validation in a thread executor.
# If `get_number_of_devices` fails, validation fails too.
mock_pytouchline.get_number_of_devices.side_effect = ConnectionError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=TEST_DATA,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# "Fix" the problem, and try again.
mock_pytouchline.get_number_of_devices.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=TEST_DATA,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_HOST
assert result["data"] == TEST_DATA
assert result["result"].unique_id == TEST_UNIQUE_ID
async def test_already_configured_by_host(hass: HomeAssistant) -> None:
"""Test abort when host is already configured."""
MockConfigEntry(domain=DOMAIN, data=TEST_DATA).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=TEST_DATA,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_already_configured_by_unique_id(hass: HomeAssistant) -> None:
"""Test abort when unique id is already configured."""
MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "5.6.7.8"},
unique_id=TEST_UNIQUE_ID,
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=TEST_DATA,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_import_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test YAML import creates an entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=TEST_DATA,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_HOST
assert result["data"] == TEST_DATA
assert result["result"].unique_id == TEST_UNIQUE_ID
assert len(mock_setup_entry.mock_calls) == 1
async def test_import_cannot_connect(hass: HomeAssistant, mock_pytouchline) -> None:
"""Test YAML import aborts when it cannot connect."""
mock_pytouchline.get_number_of_devices.side_effect = ConnectionError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=TEST_DATA,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_import_already_configured(hass: HomeAssistant) -> None:
"""Test YAML import aborts when already configured."""
MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "5.6.7.8"},
unique_id=TEST_UNIQUE_ID,
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=TEST_DATA,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"