Add support for discovering individual roombas (#45200)

* Add support for discovering individual roombas

* add missing translation string

* Update homeassistant/components/roomba/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2021-01-15 21:28:12 -10:00 committed by GitHub
parent 5e01b828af
commit 233f923cd7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 318 additions and 13 deletions

View file

@ -1,11 +1,14 @@
"""Config flow to configure roomba component.""" """Config flow to configure roomba component."""
import asyncio
from roombapy import Roomba from roombapy import Roomba
from roombapy.discovery import RoombaDiscovery from roombapy.discovery import RoombaDiscovery
from roombapy.getpassword import RoombaPassword from roombapy.getpassword import RoombaPassword
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, core from homeassistant import config_entries, core
from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS
from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback from homeassistant.core import callback
@ -21,6 +24,8 @@ from .const import (
) )
from .const import DOMAIN # pylint:disable=unused-import from .const import DOMAIN # pylint:disable=unused-import
ROOMBA_DISCOVERY_LOCK = "roomba_discovery_lock"
DEFAULT_OPTIONS = {CONF_CONTINUOUS: DEFAULT_CONTINUOUS, CONF_DELAY: DEFAULT_DELAY} DEFAULT_OPTIONS = {CONF_CONTINUOUS: DEFAULT_CONTINUOUS, CONF_DELAY: DEFAULT_DELAY}
MAX_NUM_DEVICES_TO_DISCOVER = 25 MAX_NUM_DEVICES_TO_DISCOVER = 25
@ -72,6 +77,35 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return OptionsFlowHandler(config_entry) return OptionsFlowHandler(config_entry)
async def async_step_dhcp(self, dhcp_discovery):
"""Handle dhcp discovery."""
if self._async_host_already_configured(dhcp_discovery[IP_ADDRESS]):
return self.async_abort(reason="already_configured")
if not dhcp_discovery[HOSTNAME].startswith("iRobot-"):
return self.async_abort(reason="not_irobot_device")
blid = _async_blid_from_hostname(dhcp_discovery[HOSTNAME])
await self.async_set_unique_id(blid)
self._abort_if_unique_id_configured(
updates={CONF_HOST: dhcp_discovery[IP_ADDRESS]}
)
self.host = dhcp_discovery[IP_ADDRESS]
self.blid = blid
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context["title_placeholders"] = {"host": self.host, "name": self.blid}
return await self.async_step_user()
async def _async_start_link(self):
"""Start linking."""
device = self.discovered_robots[self.host]
self.blid = device.blid
self.name = device.robot_name
await self.async_set_unique_id(self.blid, raise_on_progress=False)
self._abort_if_unique_id_configured()
return await self.async_step_link()
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Handle a flow start.""" """Handle a flow start."""
# Check if user chooses manual entry # Check if user chooses manual entry
@ -84,15 +118,12 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
and user_input[CONF_HOST] in self.discovered_robots and user_input[CONF_HOST] in self.discovered_robots
): ):
self.host = user_input[CONF_HOST] self.host = user_input[CONF_HOST]
device = self.discovered_robots[self.host] return await self._async_start_link()
self.blid = device.blid
self.name = device.robot_name
await self.async_set_unique_id(self.blid, raise_on_progress=False)
self._abort_if_unique_id_configured()
return await self.async_step_link()
already_configured = self._async_current_ids(False) already_configured = self._async_current_ids(False)
discovery = _async_get_roomba_discovery() discovery = _async_get_roomba_discovery()
async with self.hass.data.setdefault(ROOMBA_DISCOVERY_LOCK, asyncio.Lock()):
devices = await self.hass.async_add_executor_job(discovery.get_all) devices = await self.hass.async_add_executor_job(discovery.get_all)
if devices: if devices:
@ -102,6 +133,14 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
for device in devices for device in devices
if device.blid not in already_configured if device.blid not in already_configured
} }
if self.host and self.host in self.discovered_robots:
# From discovery
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context["title_placeholders"] = {
"host": self.host,
"name": self.discovered_robots[self.host].robot_name,
}
return await self._async_start_link()
if not self.discovered_robots: if not self.discovered_robots:
return await self.async_step_manual() return await self.async_step_manual()
@ -131,7 +170,10 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="manual", step_id="manual",
description_placeholders={AUTH_HELP_URL_KEY: AUTH_HELP_URL_VALUE}, description_placeholders={AUTH_HELP_URL_KEY: AUTH_HELP_URL_VALUE},
data_schema=vol.Schema( data_schema=vol.Schema(
{vol.Required(CONF_HOST): str, vol.Required(CONF_BLID): str} {
vol.Required(CONF_HOST, default=self.host): str,
vol.Required(CONF_BLID, default=self.blid): str,
}
), ),
) )
@ -154,7 +196,10 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
to connect to the device. to connect to the device.
""" """
if user_input is None: if user_input is None:
return self.async_show_form(step_id="link") return self.async_show_form(
step_id="link",
description_placeholders={CONF_NAME: self.name or self.blid},
)
try: try:
password = await self.hass.async_add_executor_job( password = await self.hass.async_add_executor_job(
@ -211,6 +256,14 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
@callback
def _async_host_already_configured(self, host):
"""See if we already have an entry matching the host."""
for entry in self._async_current_entries():
if entry.data.get(CONF_HOST) == host:
return True
return False
class OptionsFlowHandler(config_entries.OptionsFlow): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options.""" """Handle options."""
@ -251,3 +304,9 @@ def _async_get_roomba_discovery():
discovery = RoombaDiscovery() discovery = RoombaDiscovery()
discovery.amount_of_broadcasted_messages = MAX_NUM_DEVICES_TO_DISCOVER discovery.amount_of_broadcasted_messages = MAX_NUM_DEVICES_TO_DISCOVER
return discovery return discovery
@callback
def _async_blid_from_hostname(hostname):
"""Extract the blid from the hostname."""
return hostname.split("-")[1].split(".")[0]

View file

@ -1,5 +1,6 @@
{ {
"config": { "config": {
"flow_title": "iRobot {name} ({host})",
"step": { "step": {
"init": { "init": {
"title": "Automaticlly connect to the device", "title": "Automaticlly connect to the device",
@ -18,7 +19,7 @@
}, },
"link": { "link": {
"title": "Retrieve Password", "title": "Retrieve Password",
"description": "Press and hold the Home button until the device generates a sound (about two seconds)." "description": "Press and hold the Home button on {name} until the device generates a sound (about two seconds)."
}, },
"link_manual": { "link_manual": {
"title": "Enter Password", "title": "Enter Password",
@ -32,7 +33,9 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}, },
"abort": { "abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"not_irobot_device": "Discovered device is not an iRobot device"
} }
}, },
"options": { "options": {

View file

@ -1,5 +1,6 @@
{ {
"config": { "config": {
"flow_title": "iRobot {name} ({host})",
"step": { "step": {
"init": { "init": {
"title": "Automaticlly connect to the device", "title": "Automaticlly connect to the device",
@ -18,7 +19,7 @@
}, },
"link": { "link": {
"title": "Retrieve Password", "title": "Retrieve Password",
"description": "Press and hold the Home button until the device generates a sound (about two seconds)." "description": "Press and hold the Home button on {name} until the device generates a sound (about two seconds)."
}, },
"link_manual": { "link_manual": {
"title": "Enter Password", "title": "Enter Password",
@ -32,7 +33,9 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}, },
"abort": { "abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"not_irobot_device": "Discovered device not an iRobot device"
} }
}, },
"options": { "options": {

View file

@ -5,6 +5,7 @@ from roombapy import RoombaConnectionError
from roombapy.roomba import RoombaInfo from roombapy.roomba import RoombaInfo
from homeassistant import config_entries, data_entry_flow, setup from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS
from homeassistant.components.roomba.const import ( from homeassistant.components.roomba.const import (
CONF_BLID, CONF_BLID,
CONF_CONTINUOUS, CONF_CONTINUOUS,
@ -579,3 +580,242 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused(ha
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_dhcp_discovery_and_roomba_discovery_finds(hass):
"""Test we can process the discovery from dhcp and roomba discovery matches the device."""
await setup.async_setup_component(hass, "persistent_notification", {})
mocked_roomba = _create_mocked_roomba(
roomba_connected=True,
master_state={"state": {"reported": {"name": "myroomba"}}},
)
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={
IP_ADDRESS: MOCK_IP,
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
HOSTNAME: "iRobot-blid",
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] is None
assert result["step_id"] == "link"
assert result["description_placeholders"] == {"name": "robot_name"}
with patch(
"homeassistant.components.roomba.config_flow.Roomba",
return_value=mocked_roomba,
), patch(
"homeassistant.components.roomba.config_flow.RoombaPassword",
_mocked_getpassword,
), patch(
"homeassistant.components.roomba.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.roomba.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "robot_name"
assert result2["result"].unique_id == "blid"
assert result2["data"] == {
CONF_BLID: "blid",
CONF_CONTINUOUS: True,
CONF_DELAY: 1,
CONF_HOST: MOCK_IP,
CONF_PASSWORD: "password",
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_dhcp_discovery_falls_back_to_manual(hass):
"""Test we can process the discovery from dhcp but roomba discovery cannot find the device."""
await setup.async_setup_component(hass, "persistent_notification", {})
mocked_roomba = _create_mocked_roomba(
roomba_connected=True,
master_state={"state": {"reported": {"name": "myroomba"}}},
)
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={
IP_ADDRESS: "1.1.1.1",
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
HOSTNAME: "iRobot-blid",
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] is None
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] is None
assert result2["step_id"] == "manual"
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_BLID: "blid"},
)
await hass.async_block_till_done()
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result3["errors"] is None
with patch(
"homeassistant.components.roomba.config_flow.Roomba",
return_value=mocked_roomba,
), patch(
"homeassistant.components.roomba.config_flow.RoombaPassword",
_mocked_getpassword,
), patch(
"homeassistant.components.roomba.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.roomba.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{},
)
await hass.async_block_till_done()
assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result4["title"] == "myroomba"
assert result4["result"].unique_id == "blid"
assert result4["data"] == {
CONF_BLID: "blid",
CONF_CONTINUOUS: True,
CONF_DELAY: 1,
CONF_HOST: "1.1.1.1",
CONF_PASSWORD: "password",
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_dhcp_discovery_with_ignored(hass):
"""Test ignored entries do not break checking for existing entries."""
await setup.async_setup_component(hass, "persistent_notification", {})
config_entry = MockConfigEntry(domain=DOMAIN, data={}, source="ignore")
config_entry.add_to_hass(hass)
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={
IP_ADDRESS: "1.1.1.1",
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
HOSTNAME: "iRobot-blid",
},
)
await hass.async_block_till_done()
assert result["type"] == "form"
async def test_dhcp_discovery_already_configured_host(hass):
"""Test we abort if the host is already configured."""
await setup.async_setup_component(hass, "persistent_notification", {})
config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "1.1.1.1"})
config_entry.add_to_hass(hass)
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={
IP_ADDRESS: "1.1.1.1",
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
HOSTNAME: "iRobot-blid",
},
)
await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
async def test_dhcp_discovery_already_configured_blid(hass):
"""Test we abort if the blid is already configured."""
await setup.async_setup_component(hass, "persistent_notification", {})
config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_BLID: "blid"}, unique_id="blid"
)
config_entry.add_to_hass(hass)
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={
IP_ADDRESS: "1.1.1.1",
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
HOSTNAME: "iRobot-blid",
},
)
await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
async def test_dhcp_discovery_not_irobot(hass):
"""Test we abort if the discovered device is not an irobot device."""
await setup.async_setup_component(hass, "persistent_notification", {})
config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_BLID: "blid"}, unique_id="blid"
)
config_entry.add_to_hass(hass)
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={
IP_ADDRESS: "1.1.1.1",
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
HOSTNAME: "NotiRobot-blid",
},
)
await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "not_irobot_device"