diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index e8b8c3bb433..1770e902b0f 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -3,19 +3,63 @@ from __future__ import annotations import logging +import os.path from homeassistant.components.homeassistant_hardware.util import guess_firmware_info +from homeassistant.components.usb import USBDevice, async_register_port_event_callback from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT +from .const import DESCRIPTION, DEVICE, DOMAIN, FIRMWARE, FIRMWARE_VERSION, PRODUCT _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ZBT-1 integration.""" + + @callback + def async_port_event_callback( + added: set[USBDevice], removed: set[USBDevice] + ) -> None: + """Handle USB port events.""" + current_entries_by_path = { + entry.data[DEVICE]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + for device in added | removed: + path = device.device + entry = current_entries_by_path.get(path) + + if entry is not None: + _LOGGER.debug( + "Device %r has changed state, reloading config entry %s", + path, + entry, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + async_register_port_event_callback(hass, async_port_event_callback) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" + + # Postpone loading the config entry if the device is missing + device_path = entry.data[DEVICE] + if not await hass.async_add_executor_job(os.path.exists, device_path): + raise ConfigEntryNotReady + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index c5bfa4bd609..89ec292d879 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -47,3 +47,13 @@ def mock_zha_get_last_network_settings() -> Generator[None]: AsyncMock(return_value=None), ): yield + + +@pytest.fixture(autouse=True) +def mock_usb_path_exists() -> Generator[None]: + """Mock os.path.exists to allow the ZBT-1 integration to load.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index c467a9e0d60..f38ac158e71 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -1,15 +1,28 @@ """Test the Home Assistant SkyConnect integration.""" +from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.usb import ( + async_request_scan, + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: @@ -58,3 +71,119 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: } await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: + """Test setup failing when the USB port is missing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + # Set up the config entry + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_exists.return_value = True + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now it's ready + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_usb_device_reactivity(hass: HomeAssistant) -> None: + """Test setting up USB monitoring.""" + assert await async_setup_component(hass, "usb", {"usb": {}}) + + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + # Now we make it available but do not wait + mock_exists.return_value = True + + with patch_scanned_serial_ports( + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="3c0ed67c628beb11b1cd64a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_request_scan(hass) + + # It loads immediately + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Wait for a bit for the USB scan debouncer to cool off + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=5)) + + # Unplug the stick + mock_exists.return_value = False + + with patch_scanned_serial_ports(return_value=[]): + await async_request_scan(hass) + + # The integration has reloaded and is now in a failed state + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY