Reload the ZBT-1 integration on USB state changes (#141287)

* Reload the config entry when the ZBT-1 is unplugged

* Register the USB event handler globally to react better to re-plugs

* Fix existing unit tests

* Add an empty `CONFIG_SCHEMA`

* Add a unit test

* Fix unit tests

* Fix unit tests for Linux

* Address most review comments

* Address remaining review comments
This commit is contained in:
puddly 2025-03-31 15:10:24 -04:00 committed by Franck Nijhof
parent b428196149
commit a7c43f9b49
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
3 changed files with 186 additions and 3 deletions

View File

@ -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

View File

@ -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

View File

@ -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