diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index e6775f5baca..5a57f9f4198 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -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() diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 756e14fc545..32fec3951ae 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -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]: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 3db8e40f9f4..47db9b701bf 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -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: diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index 20cc8e7cc69..d1370f613ad 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -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, diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py index dd662b411dd..1924c91376b 100644 --- a/homeassistant/components/incomfort/entity.py +++ b/homeassistant/components/incomfort/entity.py @@ -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) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index f404f33b970..65d781b1189 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -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"], diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 8bcfa4ce5e1..a59dc71d87f 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -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%]" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 5fef087a868..7d14ab0f444 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -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*", diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index 3829c42d07f..aacfa886f52 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -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", diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 9ab5a672d61..e102595657f 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -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,