From a1fb1307d1c46244d7a57530c9c79f97a66cf2d5 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 12 Jan 2023 17:46:50 +0100 Subject: [PATCH] Remove sky connect config entry if USB stick is not plugged in --- .../homeassistant_sky_connect/__init__.py | 31 +++++++++-- homeassistant/components/usb/__init__.py | 52 ++++++++++++++++++- .../test_hardware.py | 8 +-- .../homeassistant_sky_connect/test_init.py | 31 +++++++++-- 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 08d54bdef12..32e9dbe3098 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -1,6 +1,7 @@ """The Home Assistant Sky Connect integration.""" from __future__ import annotations +import asyncio import logging from homeassistant.components import usb @@ -16,7 +17,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN @@ -64,6 +65,30 @@ async def _multi_pan_addon_info( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Sky Connect config entry.""" + + usb_discovery_started: asyncio.Future[None] = asyncio.Future() + + @callback + def async_usb_discovery_started(hass: HomeAssistant) -> None: + """Handle usb discovery started.""" + if not usb_discovery_started.cancelled(): + usb_discovery_started.set_result(None) + + @callback + def cancel_startup() -> None: + """Stop waiting for USB discovery started.""" + unsub_usb() + if not usb_discovery_started.cancelled(): + usb_discovery_started.cancel() + + unsub_usb = usb.async_at_discovery_started(hass, async_usb_discovery_started) + entry.async_on_unload(cancel_startup) + + try: + await usb_discovery_started + except asyncio.CancelledError: + return False + matcher = usb.USBCallbackMatcher( domain=DOMAIN, vid=entry.data["vid"].upper(), @@ -74,8 +99,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) if not usb.async_is_plugged_in(hass, matcher): - # The USB dongle is not plugged in - raise ConfigEntryNotReady + # The USB dongle is not plugged in, remove the config entry + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) addon_info = await _multi_pan_addon_info(hass, entry) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 0f81d2e42d6..c8b55577030 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -1,7 +1,7 @@ """The USB Discovery integration.""" from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine import dataclasses import fnmatch import logging @@ -20,12 +20,17 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT from homeassistant.core import ( CALLBACK_TYPE, Event, + HassJob, HomeAssistant, callback as hass_callback, ) from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -40,6 +45,8 @@ _LOGGER = logging.getLogger(__name__) REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown +USB_DISCOVERY_STARTED = "usb_discovery_started" + __all__ = [ "async_is_plugged_in", "async_register_scan_request_callback", @@ -89,6 +96,46 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo ) +@hass_callback +def async_at_discovery_started( + hass: HomeAssistant, + at_start_cb: Callable[[HomeAssistant], Coroutine[Any, Any, None] | None], +) -> CALLBACK_TYPE: + """Execute a job at_start_cb when USB discovery has started. + + The job is executed immediately if USB discovery is already started. + """ + discovery: USBDiscovery = hass.data[DOMAIN] + at_start_job = HassJob(at_start_cb) + + if discovery.started: + hass.async_run_hass_job(at_start_job, hass) + return lambda: None + + unsub: None | CALLBACK_TYPE = None + + @hass_callback + def _usb_started() -> None: + """Call the callback when USB discovery started.""" + hass.async_run_hass_job(at_start_job, hass) + nonlocal unsub + if unsub is not None: + unsub() + unsub = None + + @hass_callback + def cancel() -> None: + if unsub is not None: + unsub() + + unsub = async_dispatcher_connect( + hass, + USB_DISCOVERY_STARTED, + _usb_started, + ) + return cancel + + @dataclasses.dataclass class UsbServiceInfo(BaseServiceInfo): """Prepared info from usb entries.""" @@ -184,6 +231,7 @@ class USBDiscovery: self.usb = usb self.seen: set[tuple[str, ...]] = set() self.observer_active = False + self.started = False self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_callbacks: list[CALLBACK_TYPE] = [] @@ -195,6 +243,8 @@ class USBDiscovery: async def async_start(self, event: Event) -> None: """Start USB Discovery and run a manual scan.""" await self._async_scan_serial() + async_dispatcher_send(self.hass, USB_DISCOVERY_STARTED) + self.started = True async def _async_start_monitor(self) -> None: """Start monitoring hardware with pyudev.""" diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index 01f0e6ac5d7..09e650388c5 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -2,9 +2,10 @@ from unittest.mock import patch from homeassistant.components.homeassistant_sky_connect.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.common import MockConfigEntry CONFIG_ENTRY_DATA = { "device": "bla_device", @@ -29,7 +30,8 @@ async def test_hardware_info( hass: HomeAssistant, hass_ws_client, addon_store_info ) -> None: """Test we can get the board info.""" - mock_integration(hass, MockModule("usb")) + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) # Setup the config entry config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index ebf1c74d9e0..e3918b4a076 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -1,4 +1,5 @@ """Test the Home Assistant Sky Connect integration.""" +import asyncio from collections.abc import Generator from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -9,7 +10,8 @@ from homeassistant.components import zha from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -55,6 +57,9 @@ async def test_setup_entry( num_flows, ) -> None: """Test setup of a config entry, including setup of zha.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # Setup the config entry config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -100,6 +105,9 @@ async def test_setup_zha( mock_zha_config_flow_setup, hass: HomeAssistant, addon_store_info ) -> None: """Test zha gets the right config.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # Setup the config entry config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -146,6 +154,9 @@ async def test_setup_zha_multipan( hass: HomeAssistant, addon_info, addon_running ) -> None: """Test zha gets the right config.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + addon_info.return_value["options"]["device"] = CONFIG_ENTRY_DATA["device"] # Setup the config entry @@ -197,6 +208,9 @@ async def test_setup_zha_multipan_other_device( mock_zha_config_flow_setup, hass: HomeAssistant, addon_info, addon_running ) -> None: """Test zha gets the right config.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + addon_info.return_value["options"]["device"] = "/dev/not_our_sky_connect" # Setup the config entry @@ -258,16 +272,24 @@ async def test_setup_entry_wait_usb(hass: HomeAssistant) -> None: "homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in", return_value=False, ) as mock_is_plugged_in: - assert not await hass.config_entries.async_setup(config_entry.entry_id) + task = hass.async_create_task( + hass.config_entries.async_setup(config_entry.entry_id) + ) + await asyncio.sleep(0.1) + assert not task.done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(mock_is_plugged_in.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 async def test_setup_entry_addon_info_fails( hass: HomeAssistant, addon_store_info ) -> None: """Test setup of a config entry when fetching addon info fails.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + addon_store_info.side_effect = HassioAPIError("Boom") # Setup the config entry @@ -296,6 +318,9 @@ async def test_setup_entry_addon_not_running( hass: HomeAssistant, addon_installed, start_addon ) -> None: """Test the addon is started if it is not running.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # Setup the config entry config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA,