Remove sky connect config entry if USB stick is not plugged in (#85765)

* Remove sky connect config entry if USB stick is not plugged in

* Tweak cleanup

* Give some stuff more cromulent names

* Do the needful

* Add tests

* Tweak
This commit is contained in:
Erik Montnemery 2023-01-16 09:25:06 +01:00 committed by GitHub
parent fa0d653216
commit affea9a305
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 164 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

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