diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 08d54bdef12..af6df6b519d 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -16,7 +16,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 @@ -25,12 +25,10 @@ from .util import get_usb_service_info _LOGGER = logging.getLogger(__name__) -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" +async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Wait for multi-PAN info to be available.""" if not is_hassio(hass): - return None + return addon_manager: AddonManager = get_addon_manager(hass) try: @@ -50,7 +48,18 @@ async def _multi_pan_addon_info( ) raise ConfigEntryNotReady - if addon_info.state == AddonState.NOT_INSTALLED: + +async def _multi_pan_addon_info( + hass: HomeAssistant, entry: ConfigEntry +) -> AddonInfo | None: + """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" + if not is_hassio(hass): + return None + + addon_manager: AddonManager = get_addon_manager(hass) + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + + if addon_info.state != AddonState.RUNNING: return None usb_dev = entry.data["device"] @@ -62,8 +71,8 @@ async def _multi_pan_addon_info( return addon_info -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up a Home Assistant Sky Connect config entry.""" +async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Finish Home Assistant Sky Connect config entry setup.""" matcher = usb.USBCallbackMatcher( domain=DOMAIN, vid=entry.data["vid"].upper(), @@ -74,8 +83,9 @@ 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)) + return addon_info = await _multi_pan_addon_info(hass, entry) @@ -86,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: context={"source": "usb"}, data=usb_info, ) - return True + return hw_discovery_data = { "name": "Sky Connect Multi-PAN", @@ -101,6 +111,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data=hw_discovery_data, ) + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Home Assistant Sky Connect config entry.""" + + await _wait_multi_pan_addon(hass, entry) + + @callback + def async_usb_scan_done() -> None: + """Handle usb discovery started.""" + hass.async_create_task(_async_usb_scan_done(hass, entry)) + + unsub_usb = usb.async_register_initial_scan_callback(hass, async_usb_scan_done) + entry.async_on_unload(unsub_usb) + return True diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 0f81d2e42d6..17d6f679cf0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -61,6 +61,18 @@ def async_register_scan_request_callback( return discovery.async_register_scan_request_callback(callback) +@hass_callback +def async_register_initial_scan_callback( + hass: HomeAssistant, callback: CALLBACK_TYPE +) -> CALLBACK_TYPE: + """Register to receive a callback when the initial USB scan is done. + + If the initial scan is already done, the callback is called immediately. + """ + discovery: USBDiscovery = hass.data[DOMAIN] + return discovery.async_register_initial_scan_callback(callback) + + @hass_callback def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool: """Return True is a USB device is present.""" @@ -186,6 +198,8 @@ class USBDiscovery: self.observer_active = False self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_callbacks: list[CALLBACK_TYPE] = [] + self.initial_scan_done = False + self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] async def async_setup(self) -> None: """Set up USB Discovery.""" @@ -249,7 +263,7 @@ class USBDiscovery: self, _callback: CALLBACK_TYPE, ) -> CALLBACK_TYPE: - """Register a callback.""" + """Register a scan request callback.""" self._request_callbacks.append(_callback) @hass_callback @@ -258,6 +272,26 @@ class USBDiscovery: return _async_remove_callback + @hass_callback + def async_register_initial_scan_callback( + self, + callback: CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register an initial scan callback.""" + if self.initial_scan_done: + callback() + return lambda: None + + self._initial_scan_callbacks.append(callback) + + @hass_callback + def _async_remove_callback() -> None: + if callback not in self._initial_scan_callbacks: + return + self._initial_scan_callbacks.remove(callback) + + return _async_remove_callback + @hass_callback def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" @@ -307,6 +341,12 @@ class USBDiscovery: async def _async_scan_serial(self) -> None: """Scan serial ports.""" self._async_process_ports(await self.hass.async_add_executor_job(comports)) + if self.initial_scan_done: + return + + self.initial_scan_done = True + while self._initial_scan_callbacks: + self._initial_scan_callbacks.pop()() async def _async_scan(self) -> None: """Scan for USB devices and notify callbacks to scan as well.""" 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..c47066e8bc9 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -9,7 +9,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 +56,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 +104,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 +153,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 +207,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 +271,23 @@ 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) + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + # USB discovery starts, config entry should be removed + 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 +316,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, diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index c7196fed0c5..ab9f00a6a5b 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -936,3 +936,59 @@ async def test_web_socket_triggers_discovery_request_callbacks(hass, hass_ws_cli assert response["success"] await hass.async_block_till_done() assert len(mock_callback.mock_calls) == 1 + + +async def test_initial_scan_callback(hass, hass_ws_client): + """Test it's possible to register a callback when the initial scan is done.""" + mock_callback_1 = Mock() + mock_callback_2 = Mock() + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=[] + ), patch("homeassistant.components.usb.comports", return_value=[]), patch.object( + hass.config_entries.flow, "async_init" + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + cancel_1 = usb.async_register_initial_scan_callback(hass, mock_callback_1) + assert len(mock_callback_1.mock_calls) == 0 + + await hass.async_block_till_done() + assert len(mock_callback_1.mock_calls) == 0 + + # This triggers the initial scan + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(mock_callback_1.mock_calls) == 1 + + # A callback registered now should be called immediately. The old callback + # should not be called again + cancel_2 = usb.async_register_initial_scan_callback(hass, mock_callback_2) + assert len(mock_callback_1.mock_calls) == 1 + assert len(mock_callback_2.mock_calls) == 1 + + # Calling the cancels should be allowed even if the callback has been called + cancel_1() + cancel_2() + + +async def test_cancel_initial_scan_callback(hass, hass_ws_client): + """Test it's possible to cancel an initial scan callback.""" + mock_callback = Mock() + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=[] + ), patch("homeassistant.components.usb.comports", return_value=[]), patch.object( + hass.config_entries.flow, "async_init" + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + cancel = usb.async_register_initial_scan_callback(hass, mock_callback) + assert len(mock_callback.mock_calls) == 0 + + await hass.async_block_till_done() + assert len(mock_callback.mock_calls) == 0 + cancel() + + # This triggers the initial scan + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(mock_callback.mock_calls) == 0