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.core import HomeAssistant
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 .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound
@ -43,7 +45,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) ->
except TimeoutError as 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
await coordinator.async_config_entry_first_refresh()

View File

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

View File

@ -12,12 +12,15 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
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 (
BooleanSelector,
BooleanSelectorConfig,
@ -25,6 +28,7 @@ from homeassistant.helpers.selector import (
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
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(
{
vol.Optional(CONF_PASSWORD): TextSelector(
@ -94,6 +109,8 @@ async def async_try_connect_gateway(
class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow to set up an Intergas InComfort boyler and thermostats."""
_discovered_host: str
@staticmethod
@callback
def async_get_options_flow(
@ -102,6 +119,67 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@ -50,8 +50,11 @@ async def async_connect_gateway(
class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
"""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."""
self.unique_id = unique_id
super().__init__(
hass,
_LOGGER,

View File

@ -28,3 +28,5 @@ class IncomfortBoilerEntity(IncomfortEntity):
name="Boiler",
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",
"codeowners": ["@jbouwh"],
"config_flow": true,
"dhcp": [
{ "hostname": "rfgateway", "macaddress": "0004A3*" },
{ "registered_devices": true }
],
"documentation": "https://www.home-assistant.io/integrations/incomfort",
"iot_class": "local_polling",
"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."
}
},
"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": {
"data": {
"password": "[%key:common::config_flow::data::password%]"

View File

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

View File

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

View File

@ -8,15 +8,29 @@ from incomfortclient import IncomfortError, InvalidHeaterList
import pytest
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.core import HomeAssistant
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
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(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock
@ -118,6 +132,139 @@ async def test_form_validation(
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(
hass: HomeAssistant,
mock_incomfort: MagicMock,