From b41480ae466a99c977e138d335e552107e9865ac Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 21 Dec 2019 10:22:07 +0000 Subject: [PATCH] Add a config entry mechanism to rediscover a discovery that was ignored (#30099) * Mechanism to rediscover a discovery that was ignored * Add core config entry tests for new rediscover step * Add tests for homekit_controller implementation of async_step_rediscover * Rename rediscover to unignore * Comment the new ignore/unignore mechanisms --- .../homekit_controller/config_flow.py | 32 +++++ homeassistant/config_entries.py | 26 ++++ .../homekit_controller/test_config_flow.py | 82 ++++++++++++ tests/test_config_entries.py | 117 ++++++++++++++++++ 4 files changed, 257 insertions(+) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 6cc724e9fe5..3f230d923c7 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -108,6 +108,38 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): ), ) + async def async_step_unignore(self, user_input): + """Rediscover a previously ignored discover.""" + unique_id = user_input["unique_id"] + await self.async_set_unique_id(unique_id) + + records = await self.hass.async_add_executor_job(self.controller.discover, 5) + for record in records: + if normalize_hkid(record["id"]) != unique_id: + continue + return await self.async_step_zeroconf( + { + "host": record["address"], + "port": record["port"], + "hostname": record["name"], + "type": "_hap._tcp.local.", + "name": record["name"], + "properties": { + "md": record["md"], + "pv": record["pv"], + "id": unique_id, + "c#": record["c#"], + "s#": record["s#"], + "ff": record["ff"], + "ci": record["ci"], + "sf": record["sf"], + "sh": "", + }, + } + ) + + return self.async_abort(reason="no_devices") + async def async_step_zeroconf(self, discovery_info): """Handle a discovered HomeKit accessory. diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 8b314547d9c..942998767a1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -24,8 +24,17 @@ SOURCE_IMPORT = "import" SOURCE_SSDP = "ssdp" SOURCE_USER = "user" SOURCE_ZEROCONF = "zeroconf" + +# If a user wants to hide a discovery from the UI they can "Ignore" it. The config_entries/ignore_flow +# websocket command creates a config entry with this source and while it exists normal discoveries +# with the same unique id are ignored. SOURCE_IGNORE = "ignore" +# This is used when a user uses the "Stop Ignoring" button in the UI (the +# config_entries/ignore_flow websocket command). It's triggered after the "ignore" config entry has +# been removed and unloaded. +SOURCE_UNIGNORE = "unignore" + HANDLERS = Registry() STORAGE_KEY = "core.config_entries" @@ -461,6 +470,19 @@ class ConfigEntries: dev_reg.async_clear_config_entry(entry_id) ent_reg.async_clear_config_entry(entry_id) + # After we have fully removed an "ignore" config entry we can try and rediscover it so that a + # user is able to immediately start configuring it. We do this by starting a new flow with + # the 'unignore' step. If the integration doesn't implement async_step_unignore then + # this will be a no-op. + if entry.source == SOURCE_IGNORE: + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + entry.domain, + context={"source": SOURCE_UNIGNORE}, + data={"unique_id": entry.unique_id}, + ) + ) + return {"require_restart": not unload_success} async def async_initialize(self) -> None: @@ -827,6 +849,10 @@ class ConfigFlow(data_entry_flow.FlowHandler): await self.async_set_unique_id(user_input["unique_id"], raise_on_progress=False) return self.async_create_entry(title="Ignored", data={}) + async def async_step_unignore(self, user_input: Dict[str, Any]) -> Dict[str, Any]: + """Rediscover a config entry by it's unique_id.""" + return self.async_abort(reason="not_implemented") + class OptionsFlowManager: """Flow to set options for a configuration entry.""" diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 4733581f136..56c1c30e8f3 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -836,3 +836,85 @@ async def test_parse_overlapping_homekit_json(hass): "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", } + + +async def test_unignore_works(hass): + """Test rediscovery triggered disovers work.""" + discovery_info = { + "name": "TestDevice", + "address": "127.0.0.1", + "port": 8080, + "md": "TestDevice", + "pv": "1.0", + "id": "00:00:00:00:00:00", + "c#": 1, + "s#": 1, + "ff": 0, + "ci": 0, + "sf": 1, + } + + pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) + pairing.list_accessories_and_characteristics.return_value = [ + { + "aid": 1, + "services": [ + { + "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}], + "type": "3e", + } + ], + } + ] + + flow = _setup_flow_handler(hass) + + flow.controller.pairings = {"00:00:00:00:00:00": pairing} + flow.controller.discover.return_value = [discovery_info] + + result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:00"}) + assert result["type"] == "form" + assert result["step_id"] == "pair" + assert flow.context == { + "hkid": "00:00:00:00:00:00", + "title_placeholders": {"name": "TestDevice"}, + "unique_id": "00:00:00:00:00:00", + } + + # User initiates pairing by clicking on 'configure' - device enters pairing mode and displays code + result = await flow.async_step_pair({}) + assert result["type"] == "form" + assert result["step_id"] == "pair" + assert flow.controller.start_pairing.call_count == 1 + + # Pairing finalized + result = await flow.async_step_pair({"pairing_code": "111-22-33"}) + assert result["type"] == "create_entry" + assert result["title"] == "Koogeek-LS1-20833F" + assert result["data"] == pairing.pairing_data + + +async def test_unignore_ignores_missing_devices(hass): + """Test rediscovery triggered disovers handle devices that have gone away.""" + discovery_info = { + "name": "TestDevice", + "address": "127.0.0.1", + "port": 8080, + "md": "TestDevice", + "pv": "1.0", + "id": "00:00:00:00:00:00", + "c#": 1, + "s#": 1, + "ff": 0, + "ci": 0, + "sf": 1, + } + + flow = _setup_flow_handler(hass) + flow.controller.discover.return_value = [discovery_info] + + result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:01"}) + assert result["type"] == "abort" + assert flow.context == { + "unique_id": "00:00:00:00:00:01", + } diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 19f84e94570..5b694b2de87 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1186,3 +1186,120 @@ async def test_unique_id_ignore(hass, manager): assert entry.source == "ignore" assert entry.unique_id == "mock-unique-id" + + +async def test_unignore_step_form(hass, manager): + """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" + async_setup_entry = MagicMock(return_value=mock_coro(True)) + mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + + VERSION = 1 + + async def async_step_unignore(self, user_input): + unique_id = user_input["unique_id"] + await self.async_set_unique_id(unique_id) + return self.async_show_form(step_id="discovery") + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await manager.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": "mock-unique-id"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + entry = hass.config_entries.async_entries("comp")[0] + assert entry.source == "ignore" + assert entry.unique_id == "mock-unique-id" + assert entry.domain == "comp" + + await manager.async_remove(entry.entry_id) + + # Right after removal there shouldn't be an entry or active flows + assert len(hass.config_entries.async_entries("comp")) == 0 + assert len(hass.config_entries.flow.async_progress()) == 0 + + # But after a 'tick' the unignore step has run and we can see an active flow again. + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + + # and still not config entries + assert len(hass.config_entries.async_entries("comp")) == 0 + + +async def test_unignore_create_entry(hass, manager): + """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" + async_setup_entry = MagicMock(return_value=mock_coro(True)) + mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + + VERSION = 1 + + async def async_step_unignore(self, user_input): + unique_id = user_input["unique_id"] + await self.async_set_unique_id(unique_id) + return self.async_create_entry(title="yo", data={}) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await manager.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": "mock-unique-id"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + entry = hass.config_entries.async_entries("comp")[0] + assert entry.source == "ignore" + assert entry.unique_id == "mock-unique-id" + assert entry.domain == "comp" + + await manager.async_remove(entry.entry_id) + + # Right after removal there shouldn't be an entry or flow + assert len(hass.config_entries.flow.async_progress()) == 0 + assert len(hass.config_entries.async_entries("comp")) == 0 + + # But after a 'tick' the unignore step has run and we can see a config entry. + await hass.async_block_till_done() + entry = hass.config_entries.async_entries("comp")[0] + assert entry.source == "unignore" + assert entry.unique_id == "mock-unique-id" + assert entry.title == "yo" + + # And still no active flow + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_unignore_default_impl(hass, manager): + """Test that resdicovery is a no-op by default.""" + async_setup_entry = MagicMock(return_value=mock_coro(True)) + mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + + VERSION = 1 + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await manager.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": "mock-unique-id"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + entry = hass.config_entries.async_entries("comp")[0] + assert entry.source == "ignore" + assert entry.unique_id == "mock-unique-id" + assert entry.domain == "comp" + + await manager.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries("comp")) == 0 + assert len(hass.config_entries.flow.async_progress()) == 0