Add dhcp discovery to incomfort integration (#136027)

* Add dhcp discovery to incomfort integration

* Remove duplicate code

* Ensure confirmation when discovered via DHCP

* Validate hostname is not changed

* Fix test

* Create gateway device with unique_id

* Add tests for assertion on via device

* Add registered devices to allow dhcp updates

* Migrate existing entry with host match

* Always load gatewate device an check if exising entry is loaded

* Make isolated flow step for dhcp auth

* Suggestions from code review
This commit is contained in:
Jan Bouwhuis 2025-01-22 07:55:55 +01:00 committed by GitHub
parent a511610f24
commit b8632063f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 283 additions and 4 deletions

View File

@ -9,7 +9,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .coordinator import InComfortDataCoordinator, async_connect_gateway from .coordinator import InComfortDataCoordinator, async_connect_gateway
from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound
@ -43,7 +45,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) ->
except TimeoutError as exc: except TimeoutError as exc:
raise InConfortTimeout from exc raise InConfortTimeout from exc
coordinator = InComfortDataCoordinator(hass, data) # Register discovered gateway device
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
if entry.unique_id is not None
else set(),
manufacturer="Intergas",
name="RFGateway",
)
coordinator = InComfortDataCoordinator(hass, data, entry.entry_id)
entry.runtime_data = coordinator entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@ -73,6 +73,8 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
manufacturer="Intergas", manufacturer="Intergas",
name=f"Thermostat {room.room_no}", name=f"Thermostat {room.room_no}",
) )
if coordinator.unique_id:
self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id)
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:

View File

@ -12,12 +12,15 @@ import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_RECONFIGURE, SOURCE_RECONFIGURE,
ConfigEntry, ConfigEntry,
ConfigEntryState,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
) )
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
BooleanSelector, BooleanSelector,
BooleanSelectorConfig, BooleanSelectorConfig,
@ -25,6 +28,7 @@ from homeassistant.helpers.selector import (
TextSelectorConfig, TextSelectorConfig,
TextSelectorType, TextSelectorType,
) )
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import async_connect_gateway from .coordinator import async_connect_gateway
@ -45,6 +49,17 @@ CONFIG_SCHEMA = vol.Schema(
} }
) )
DHCP_CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(CONF_USERNAME): TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="admin")
),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
REAUTH_SCHEMA = vol.Schema( REAUTH_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_PASSWORD): TextSelector( vol.Optional(CONF_PASSWORD): TextSelector(
@ -94,6 +109,8 @@ async def async_try_connect_gateway(
class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow to set up an Intergas InComfort boyler and thermostats.""" """Config flow to set up an Intergas InComfort boyler and thermostats."""
_discovered_host: str
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@ -102,6 +119,67 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return InComfortOptionsFlowHandler() return InComfortOptionsFlowHandler()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a DHCP discovered Intergas Gateway device."""
self._discovered_host = discovery_info.ip
# In case we have an existing entry with the same host
# we update the entry with the unique_id for the gateway, and abort the flow
unique_id = format_mac(discovery_info.macaddress)
existing_entries_without_unique_id = [
entry
for entry in self._async_current_entries(include_ignore=False)
if entry.unique_id is None
and entry.data.get(CONF_HOST) == self._discovered_host
and entry.state is ConfigEntryState.LOADED
]
if existing_entries_without_unique_id:
self.hass.config_entries.async_update_entry(
existing_entries_without_unique_id[0], unique_id=unique_id
)
self.hass.config_entries.async_schedule_reload(
existing_entries_without_unique_id[0].entry_id
)
raise AbortFlow("already_configured")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._discovered_host})
return await self.async_step_dhcp_confirm()
async def async_step_dhcp_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm setup from discovery."""
if user_input is not None:
return await self.async_step_dhcp_auth({CONF_HOST: self._discovered_host})
return self.async_show_form(
step_id="dhcp_confirm",
description_placeholders={CONF_HOST: self._discovered_host},
)
async def async_step_dhcp_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial set up via DHCP."""
errors: dict[str, str] | None = None
data_schema: vol.Schema = DHCP_CONFIG_SCHEMA
if user_input is not None:
user_input[CONF_HOST] = self._discovered_host
if (
errors := await async_try_connect_gateway(self.hass, user_input)
) is None:
return self.async_create_entry(title=TITLE, data=user_input)
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
return self.async_show_form(
step_id="dhcp_auth",
data_schema=data_schema,
errors=errors,
description_placeholders={CONF_HOST: self._discovered_host},
)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@ -50,8 +50,11 @@ async def async_connect_gateway(
class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
"""Data coordinator for InComfort entities.""" """Data coordinator for InComfort entities."""
def __init__(self, hass: HomeAssistant, incomfort_data: InComfortData) -> None: def __init__(
self, hass: HomeAssistant, incomfort_data: InComfortData, unique_id: str | None
) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
self.unique_id = unique_id
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,

View File

@ -28,3 +28,5 @@ class IncomfortBoilerEntity(IncomfortEntity):
name="Boiler", name="Boiler",
serial_number=heater.serial_no, serial_number=heater.serial_no,
) )
if coordinator.unique_id:
self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id)

View File

@ -3,6 +3,10 @@
"name": "Intergas InComfort/Intouch Lan2RF gateway", "name": "Intergas InComfort/Intouch Lan2RF gateway",
"codeowners": ["@jbouwh"], "codeowners": ["@jbouwh"],
"config_flow": true, "config_flow": true,
"dhcp": [
{ "hostname": "rfgateway", "macaddress": "0004A3*" },
{ "registered_devices": true }
],
"documentation": "https://www.home-assistant.io/integrations/incomfort", "documentation": "https://www.home-assistant.io/integrations/incomfort",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["incomfortclient"], "loggers": ["incomfortclient"],

View File

@ -14,6 +14,22 @@
"password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices."
} }
}, },
"dhcp_auth": {
"title": "Set up Intergas InComfort Lan2RF Gateway",
"description": "Please enter authentication details for gateway {host}",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "The username to log into the gateway. This is `admin` in most cases.",
"password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices."
}
},
"dhcp_confirm": {
"title": "Set up Intergas InComfort Lan2RF Gateway",
"description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?"
},
"reauth_confirm": { "reauth_confirm": {
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"

View File

@ -253,6 +253,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "hunter*", "hostname": "hunter*",
"macaddress": "002674*", "macaddress": "002674*",
}, },
{
"domain": "incomfort",
"hostname": "rfgateway",
"macaddress": "0004A3*",
},
{
"domain": "incomfort",
"registered_devices": True,
},
{ {
"domain": "insteon", "domain": "insteon",
"macaddress": "000EF3*", "macaddress": "000EF3*",

View File

@ -18,6 +18,11 @@ MOCK_CONFIG = {
"password": "verysecret", "password": "verysecret",
} }
MOCK_CONFIG_DHCP = {
"username": "admin",
"password": "verysecret",
}
MOCK_HEATER_STATUS = { MOCK_HEATER_STATUS = {
"display_code": DisplayCode.STANDBY, "display_code": DisplayCode.STANDBY,
"display_text": "standby", "display_text": "standby",

View File

@ -8,15 +8,29 @@ from incomfortclient import IncomfortError, InvalidHeaterList
import pytest import pytest
from homeassistant.components.incomfort.const import DOMAIN from homeassistant.components.incomfort.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import MOCK_CONFIG from .conftest import MOCK_CONFIG, MOCK_CONFIG_DHCP
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
DHCP_SERVICE_INFO = DhcpServiceInfo(
hostname="rfgateway",
ip="192.168.1.12",
macaddress="0004A3DEADFF",
)
DHCP_SERVICE_INFO_ALT = DhcpServiceInfo(
hostname="rfgateway",
ip="192.168.1.99",
macaddress="0004A3DEADFF",
)
async def test_form( async def test_form(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock
@ -118,6 +132,139 @@ async def test_form_validation(
assert "errors" not in result assert "errors" not in result
async def test_dhcp_flow_simple(
hass: HomeAssistant,
mock_incomfort: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test dhcp flow for older gateway without authentication needed.
Assert on the creation of the gateway device, climate and boiler devices.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "dhcp_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {"host": "192.168.1.12"}
config_entry: ConfigEntry = result["result"]
entry_id = config_entry.entry_id
await hass.async_block_till_done(wait_background_tasks=True)
# Check the gateway device is discovered
gateway_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)})
assert gateway_device is not None
assert gateway_device.name == "RFGateway"
assert gateway_device.manufacturer == "Intergas"
assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")}
devices = device_registry.devices.get_devices_for_config_entry_id(entry_id)
assert len(devices) == 3
boiler_device = device_registry.async_get_device(
identifiers={(DOMAIN, "c0ffeec0ffee")}
)
assert boiler_device.via_device_id == gateway_device.id
assert boiler_device is not None
climate_device = device_registry.async_get_device(
identifiers={(DOMAIN, "c0ffeec0ffee_1")}
)
assert climate_device is not None
assert climate_device.via_device_id == gateway_device.id
# Check the host is dynamically updated
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO_ALT
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert config_entry.data[CONF_HOST] == DHCP_SERVICE_INFO_ALT.ip
async def test_dhcp_flow_migrates_existing_entry_without_unique_id(
hass: HomeAssistant,
mock_incomfort: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test dhcp flow migrates an existing entry without unique_id."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
# Check the gateway device is discovered after a reload
# And has updated connections
gateway_device = device_registry.async_get_device(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
assert gateway_device is not None
assert gateway_device.name == "RFGateway"
assert gateway_device.manufacturer == "Intergas"
assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")}
devices = device_registry.devices.get_devices_for_config_entry_id(
mock_config_entry.entry_id
)
assert len(devices) == 3
boiler_device = device_registry.async_get_device(
identifiers={(DOMAIN, "c0ffeec0ffee")}
)
assert boiler_device.via_device_id == gateway_device.id
assert boiler_device is not None
climate_device = device_registry.async_get_device(
identifiers={(DOMAIN, "c0ffeec0ffee_1")}
)
assert climate_device is not None
assert climate_device.via_device_id == gateway_device.id
async def test_dhcp_flow_wih_auth(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock
) -> None:
"""Test dhcp flow for with authentication."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "dhcp_confirm"
# Try again, but now with the correct host, but still with an auth error
with patch.object(
mock_incomfort(),
"heaters",
side_effect=IncomfortError(ClientResponseError(None, None, status=401)),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.12"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "dhcp_auth"
assert result["errors"] == {CONF_PASSWORD: "auth_error"}
# Submit the form with added credentials
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG_DHCP
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway"
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
async def test_reauth_flow_success( async def test_reauth_flow_success(
hass: HomeAssistant, hass: HomeAssistant,
mock_incomfort: MagicMock, mock_incomfort: MagicMock,