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:
parent
fa0d653216
commit
affea9a305
5 changed files with 164 additions and 19 deletions
|
@ -16,7 +16,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
||||||
get_zigbee_socket,
|
get_zigbee_socket,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
@ -25,12 +25,10 @@ from .util import get_usb_service_info
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _multi_pan_addon_info(
|
async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
hass: HomeAssistant, entry: ConfigEntry
|
"""Wait for multi-PAN info to be available."""
|
||||||
) -> AddonInfo | None:
|
|
||||||
"""Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect."""
|
|
||||||
if not is_hassio(hass):
|
if not is_hassio(hass):
|
||||||
return None
|
return
|
||||||
|
|
||||||
addon_manager: AddonManager = get_addon_manager(hass)
|
addon_manager: AddonManager = get_addon_manager(hass)
|
||||||
try:
|
try:
|
||||||
|
@ -50,7 +48,18 @@ async def _multi_pan_addon_info(
|
||||||
)
|
)
|
||||||
raise ConfigEntryNotReady
|
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
|
return None
|
||||||
|
|
||||||
usb_dev = entry.data["device"]
|
usb_dev = entry.data["device"]
|
||||||
|
@ -62,8 +71,8 @@ async def _multi_pan_addon_info(
|
||||||
return addon_info
|
return addon_info
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Set up a Home Assistant Sky Connect config entry."""
|
"""Finish Home Assistant Sky Connect config entry setup."""
|
||||||
matcher = usb.USBCallbackMatcher(
|
matcher = usb.USBCallbackMatcher(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
vid=entry.data["vid"].upper(),
|
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):
|
if not usb.async_is_plugged_in(hass, matcher):
|
||||||
# The USB dongle is not plugged in
|
# The USB dongle is not plugged in, remove the config entry
|
||||||
raise ConfigEntryNotReady
|
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
|
||||||
|
return
|
||||||
|
|
||||||
addon_info = await _multi_pan_addon_info(hass, entry)
|
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"},
|
context={"source": "usb"},
|
||||||
data=usb_info,
|
data=usb_info,
|
||||||
)
|
)
|
||||||
return True
|
return
|
||||||
|
|
||||||
hw_discovery_data = {
|
hw_discovery_data = {
|
||||||
"name": "Sky Connect Multi-PAN",
|
"name": "Sky Connect Multi-PAN",
|
||||||
|
@ -101,6 +111,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
data=hw_discovery_data,
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -61,6 +61,18 @@ def async_register_scan_request_callback(
|
||||||
return discovery.async_register_scan_request_callback(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
|
@hass_callback
|
||||||
def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool:
|
def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool:
|
||||||
"""Return True is a USB device is present."""
|
"""Return True is a USB device is present."""
|
||||||
|
@ -186,6 +198,8 @@ class USBDiscovery:
|
||||||
self.observer_active = False
|
self.observer_active = False
|
||||||
self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
|
self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
|
||||||
self._request_callbacks: list[CALLBACK_TYPE] = []
|
self._request_callbacks: list[CALLBACK_TYPE] = []
|
||||||
|
self.initial_scan_done = False
|
||||||
|
self._initial_scan_callbacks: list[CALLBACK_TYPE] = []
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Set up USB Discovery."""
|
"""Set up USB Discovery."""
|
||||||
|
@ -249,7 +263,7 @@ class USBDiscovery:
|
||||||
self,
|
self,
|
||||||
_callback: CALLBACK_TYPE,
|
_callback: CALLBACK_TYPE,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Register a callback."""
|
"""Register a scan request callback."""
|
||||||
self._request_callbacks.append(_callback)
|
self._request_callbacks.append(_callback)
|
||||||
|
|
||||||
@hass_callback
|
@hass_callback
|
||||||
|
@ -258,6 +272,26 @@ class USBDiscovery:
|
||||||
|
|
||||||
return _async_remove_callback
|
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
|
@hass_callback
|
||||||
def _async_process_discovered_usb_device(self, device: USBDevice) -> None:
|
def _async_process_discovered_usb_device(self, device: USBDevice) -> None:
|
||||||
"""Process a USB discovery."""
|
"""Process a USB discovery."""
|
||||||
|
@ -307,6 +341,12 @@ class USBDiscovery:
|
||||||
async def _async_scan_serial(self) -> None:
|
async def _async_scan_serial(self) -> None:
|
||||||
"""Scan serial ports."""
|
"""Scan serial ports."""
|
||||||
self._async_process_ports(await self.hass.async_add_executor_job(comports))
|
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:
|
async def _async_scan(self) -> None:
|
||||||
"""Scan for USB devices and notify callbacks to scan as well."""
|
"""Scan for USB devices and notify callbacks to scan as well."""
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
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 = {
|
CONFIG_ENTRY_DATA = {
|
||||||
"device": "bla_device",
|
"device": "bla_device",
|
||||||
|
@ -29,7 +30,8 @@ async def test_hardware_info(
|
||||||
hass: HomeAssistant, hass_ws_client, addon_store_info
|
hass: HomeAssistant, hass_ws_client, addon_store_info
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we can get the board info."""
|
"""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
|
# Setup the config entry
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
|
|
|
@ -9,7 +9,8 @@ from homeassistant.components import zha
|
||||||
from homeassistant.components.hassio.handler import HassioAPIError
|
from homeassistant.components.hassio.handler import HassioAPIError
|
||||||
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
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
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
@ -55,6 +56,9 @@ async def test_setup_entry(
|
||||||
num_flows,
|
num_flows,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setup of a config entry, including setup of zha."""
|
"""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
|
# Setup the config entry
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
data=CONFIG_ENTRY_DATA,
|
data=CONFIG_ENTRY_DATA,
|
||||||
|
@ -100,6 +104,9 @@ async def test_setup_zha(
|
||||||
mock_zha_config_flow_setup, hass: HomeAssistant, addon_store_info
|
mock_zha_config_flow_setup, hass: HomeAssistant, addon_store_info
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test zha gets the right config."""
|
"""Test zha gets the right config."""
|
||||||
|
assert await async_setup_component(hass, "usb", {})
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||||
|
|
||||||
# Setup the config entry
|
# Setup the config entry
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
data=CONFIG_ENTRY_DATA,
|
data=CONFIG_ENTRY_DATA,
|
||||||
|
@ -146,6 +153,9 @@ async def test_setup_zha_multipan(
|
||||||
hass: HomeAssistant, addon_info, addon_running
|
hass: HomeAssistant, addon_info, addon_running
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test zha gets the right config."""
|
"""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"]
|
addon_info.return_value["options"]["device"] = CONFIG_ENTRY_DATA["device"]
|
||||||
|
|
||||||
# Setup the config entry
|
# 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
|
mock_zha_config_flow_setup, hass: HomeAssistant, addon_info, addon_running
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test zha gets the right config."""
|
"""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"
|
addon_info.return_value["options"]["device"] = "/dev/not_our_sky_connect"
|
||||||
|
|
||||||
# Setup the config entry
|
# 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",
|
"homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
) as mock_is_plugged_in:
|
) 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()
|
await hass.async_block_till_done()
|
||||||
assert len(mock_is_plugged_in.mock_calls) == 1
|
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(
|
async def test_setup_entry_addon_info_fails(
|
||||||
hass: HomeAssistant, addon_store_info
|
hass: HomeAssistant, addon_store_info
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setup of a config entry when fetching addon info fails."""
|
"""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")
|
addon_store_info.side_effect = HassioAPIError("Boom")
|
||||||
|
|
||||||
# Setup the config entry
|
# Setup the config entry
|
||||||
|
@ -296,6 +316,9 @@ async def test_setup_entry_addon_not_running(
|
||||||
hass: HomeAssistant, addon_installed, start_addon
|
hass: HomeAssistant, addon_installed, start_addon
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the addon is started if it is not running."""
|
"""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
|
# Setup the config entry
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
data=CONFIG_ENTRY_DATA,
|
data=CONFIG_ENTRY_DATA,
|
||||||
|
|
|
@ -936,3 +936,59 @@ async def test_web_socket_triggers_discovery_request_callbacks(hass, hass_ws_cli
|
||||||
assert response["success"]
|
assert response["success"]
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert len(mock_callback.mock_calls) == 1
|
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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue