From ab0d35df927958786342b3c28bd54db66a78746a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 May 2023 09:12:19 -0500 Subject: [PATCH] Add zeroconf support to roomba (#93309) --- .../components/roomba/config_flow.py | 28 ++++++--- homeassistant/components/roomba/manifest.json | 12 +++- homeassistant/generated/zeroconf.py | 10 ++++ tests/components/roomba/test_config_flow.py | 60 ++++++++++++++----- 4 files changed, 88 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 0a1c51ca38c..e4fb45865a2 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -10,7 +10,7 @@ from roombapy.getpassword import RoombaPassword import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components import dhcp +from homeassistant.components import dhcp, zeroconf from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -85,17 +85,31 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + return await self._async_step_discovery( + discovery_info.host, discovery_info.hostname.lower().rstrip(".local.") + ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + return await self._async_step_discovery( + discovery_info.ip, discovery_info.hostname + ) - if not discovery_info.hostname.startswith(("irobot-", "roomba-")): + async def _async_step_discovery(self, ip_address: str, hostname: str) -> FlowResult: + """Handle any discovery.""" + self._async_abort_entries_match({CONF_HOST: ip_address}) + + if not hostname.startswith(("irobot-", "roomba-")): return self.async_abort(reason="not_irobot_device") - self.host = discovery_info.ip - self.blid = _async_blid_from_hostname(discovery_info.hostname) + self.host = ip_address + self.blid = _async_blid_from_hostname(hostname) await self.async_set_unique_id(self.blid) - self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address}) # Because the hostname is so long some sources may # truncate the hostname since it will be longer than @@ -103,7 +117,7 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # going for a longer hostname we abort so the user # does not see two flows if discovery fails. for progress in self._async_in_progress(): - flow_unique_id = progress["context"]["unique_id"] + flow_unique_id: str = progress["context"]["unique_id"] if flow_unique_id.startswith(self.blid): return self.async_abort(reason="short_blid") if self.blid.startswith(flow_unique_id): diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 7b437a4f8c4..9e18465922a 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,5 +24,15 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.8"] + "requirements": ["roombapy==1.6.8"], + "zeroconf": [ + { + "type": "_amzn-alexa._tcp.local.", + "name": "irobot-*" + }, + { + "type": "_amzn-alexa._tcp.local.", + "name": "roomba-*" + } + ] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1771d9d63bf..93ccb404ae4 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,16 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_amzn-alexa._tcp.local.": [ + { + "domain": "roomba", + "name": "irobot-*", + }, + { + "domain": "roomba", + "name": "roomba-*", + }, + ], "_androidtvremote2._tcp.local.": [ { "domain": "androidtv_remote", diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index b0ab7a49294..0b39c34d3b8 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -5,7 +5,7 @@ import pytest from roombapy import RoombaConnectionError, RoombaInfo from homeassistant import config_entries, data_entry_flow -from homeassistant.components import dhcp +from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD @@ -16,16 +16,46 @@ from tests.common import MockConfigEntry MOCK_IP = "1.2.3.4" VALID_CONFIG = {CONF_HOST: MOCK_IP, CONF_BLID: "BLID", CONF_PASSWORD: "password"} -DHCP_DISCOVERY_DEVICES = [ - dhcp.DhcpServiceInfo( - ip=MOCK_IP, - macaddress="50:14:79:DD:EE:FF", - hostname="irobot-blid", +DISCOVERY_DEVICES = [ + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="50:14:79:DD:EE:FF", + hostname="irobot-blid", + ), ), - dhcp.DhcpServiceInfo( - ip=MOCK_IP, - macaddress="80:A5:89:DD:EE:FF", - hostname="roomba-blid", + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="80:A5:89:DD:EE:FF", + hostname="roomba-blid", + ), + ), + ( + config_entries.SOURCE_ZEROCONF, + zeroconf.ZeroconfServiceInfo( + host=MOCK_IP, + hostname="irobot-blid.local.", + name="irobot-blid._amzn-alexa._tcp.local.", + type="_amzn-alexa._tcp.local.", + port=443, + properties={}, + addresses=[MOCK_IP], + ), + ), + ( + config_entries.SOURCE_ZEROCONF, + zeroconf.ZeroconfServiceInfo( + host=MOCK_IP, + hostname="roomba-blid.local.", + name="roomba-blid._amzn-alexa._tcp.local.", + type="_amzn-alexa._tcp.local.", + port=443, + properties={}, + addresses=[MOCK_IP], + ), ), ] @@ -625,9 +655,10 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize("discovery_data", DHCP_DISCOVERY_DEVICES) +@pytest.mark.parametrize("discovery_data", DISCOVERY_DEVICES) async def test_dhcp_discovery_and_roomba_discovery_finds( - hass: HomeAssistant, discovery_data + hass: HomeAssistant, + discovery_data: tuple[str, dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo], ) -> None: """Test we can process the discovery from dhcp and roomba discovery matches the device.""" @@ -635,14 +666,15 @@ async def test_dhcp_discovery_and_roomba_discovery_finds( roomba_connected=True, master_state={"state": {"reported": {"name": "myroomba"}}}, ) + source, discovery = discovery_data with patch( "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery ): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=discovery_data, + context={"source": source}, + data=discovery, ) await hass.async_block_till_done()