Account for OTBR addon existing independent of integration

This commit is contained in:
puddly
2025-01-03 16:00:01 -05:00
parent 94f964cdd6
commit 43e12f5672
6 changed files with 137 additions and 155 deletions

View File

@@ -147,10 +147,20 @@ async def get_zha_firmware_info(
hass: HomeAssistant, config_entry: ConfigEntry
) -> FirmwareInfo | None:
"""Return firmware information for the ZHA instance."""
# We only support EZSP firmware for now
if config_entry.data.get("radio_type", None) != "ezsp":
return None
device = config_entry.data.get("device", {}).get("path", None)
if device is None:
return None
try:
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.zha.helpers import get_zha_gateway
except ImportError:
# A ZHA config entry exists but ZHA isn't installed, it was probably ignored
return None
try:
@@ -160,14 +170,6 @@ async def get_zha_firmware_info(
else:
firmware_version = gateway.state.node_info.version
# We only support EZSP firmware for now
if config_entry.data["radio_type"] != "ezsp":
return None
device = config_entry.data.get("device", {}).get("path", None)
if device is None:
return None
return FirmwareInfo(
device=device,
firmware_type=ApplicationType.EZSP,
@@ -218,13 +220,38 @@ async def guess_hardware_owners(
if firmware_info is not None:
device_guesses[firmware_info.device].append(firmware_info)
# We assume that the OTBR integration will always be set up, even without the addon
for otbr_config_entry in hass.config_entries.async_entries(OTBR_DOMAIN):
firmware_info = await get_otbr_firmware_info(hass, otbr_config_entry)
if firmware_info is not None:
device_guesses[firmware_info.device].append(firmware_info)
# It may be possible for the OTBR addon to be present without the integration
if is_hassio(hass):
otbr_addon_manager = get_otbr_addon_manager(hass)
try:
otbr_addon_info = await otbr_addon_manager.async_get_addon_info()
except AddonError:
pass
else:
if otbr_addon_info.state != AddonState.NOT_INSTALLED:
otbr_path = otbr_addon_info.options.get("device")
# Only create a new entry if there are no existing OTBR ones
if otbr_path is not None and not any(
info.source == "otbr" for info in device_guesses[otbr_path]
):
device_guesses[otbr_path].append(
FirmwareInfo(
device=otbr_path,
firmware_type=ApplicationType.SPINEL,
firmware_version=None,
source="otbr",
owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)],
)
)
if is_hassio(hass):
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)

View File

@@ -189,6 +189,10 @@ def mock_addon_info(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager",
return_value=mock_otbr_manager,
),
patch(
"homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager",
return_value=mock_otbr_manager,
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager",
return_value=mock_flasher_manager,
@@ -197,6 +201,10 @@ def mock_addon_info(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio",
return_value=is_hassio,
),
patch(
"homeassistant.components.homeassistant_hardware.util.is_hassio",
return_value=is_hassio,
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type",
return_value=app_type,

View File

@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock
import pytest
from homeassistant.components.hassio import AddonError, AddonInfo, AddonState
from homeassistant.components.homeassistant_hardware.const import ZHA_DOMAIN
from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
STEP_PICK_FIRMWARE_THREAD,
STEP_PICK_FIRMWARE_ZIGBEE,
@@ -550,8 +551,8 @@ async def test_options_flow_zigbee_to_thread_zha_configured(
# Set up ZHA as well
zha_config_entry = MockConfigEntry(
domain="zha",
data={"device": {"path": TEST_DEVICE}},
domain=ZHA_DOMAIN,
data={"device": {"path": TEST_DEVICE}, "radio_type": "ezsp"},
)
zha_config_entry.add_to_hass(hass)

View File

@@ -2,16 +2,11 @@
from unittest.mock import AsyncMock, patch
from homeassistant.components.hassio import AddonError, AddonInfo, AddonState
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareGuess,
FlasherApplicationType,
get_zha_device_path,
guess_firmware_type,
probe_silabs_firmware_type,
FirmwareInfo,
guess_firmware_info,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -21,7 +16,21 @@ ZHA_CONFIG_ENTRY = MockConfigEntry(
unique_id="some_unique_id",
data={
"device": {
"path": "socket://1.2.3.4:5678",
"path": "/dev/ttyUSB1",
"baudrate": 115200,
"flow_control": None,
},
"radio_type": "ezsp",
},
version=4,
)
ZHA_CONFIG_ENTRY2 = MockConfigEntry(
domain="zha",
unique_id="some_other_unique_id",
data={
"device": {
"path": "/dev/ttyUSB2",
"baudrate": 115200,
"flow_control": None,
},
@@ -31,153 +40,90 @@ ZHA_CONFIG_ENTRY = MockConfigEntry(
)
def test_get_zha_device_path() -> None:
"""Test extracting the ZHA device path from its config entry."""
assert (
get_zha_device_path(ZHA_CONFIG_ENTRY) == ZHA_CONFIG_ENTRY.data["device"]["path"]
)
def test_get_zha_device_path_ignored_discovery() -> None:
"""Test extracting the ZHA device path from an ignored ZHA discovery."""
config_entry = MockConfigEntry(
domain="zha",
unique_id="some_unique_id",
data={},
version=4,
)
assert get_zha_device_path(config_entry) is None
async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None:
async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None:
"""Test guessing the firmware type."""
assert (await guess_firmware_type(hass, "/dev/missing")) == FirmwareGuess(
is_running=False, firmware_type=ApplicationType.EZSP, source="unknown"
assert (await guess_firmware_info(hass, "/dev/missing")) == FirmwareInfo(
device="/dev/missing",
firmware_type=ApplicationType.EZSP,
firmware_version=None,
source="unknown",
owners=[],
)
async def test_guess_firmware_type(hass: HomeAssistant) -> None:
"""Test guessing the firmware."""
path = ZHA_CONFIG_ENTRY.data["device"]["path"]
async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None:
"""Test guessing the firmware via OTBR and ZHA."""
ZHA_CONFIG_ENTRY.add_to_hass(hass)
# One instance of ZHA and two OTBRs
zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1")
zha.add_to_hass(hass)
ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.NOT_LOADED)
assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=False, firmware_type=ApplicationType.EZSP, source="zha"
otbr1 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_2")
otbr1.add_to_hass(hass)
otbr2 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_3")
otbr2.add_to_hass(hass)
# First ZHA is running with the stick
zha_firmware_info = FirmwareInfo(
device="/dev/serial/by-id/device1",
firmware_type=ApplicationType.EZSP,
firmware_version=None,
source="zha",
owners=[AsyncMock(is_running=AsyncMock(return_value=True))],
)
# When ZHA is running, we indicate as such when guessing
ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.LOADED)
assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=True, firmware_type=ApplicationType.EZSP, source="zha"
# First OTBR: neither the addon or the integration are loaded
otbr_firmware_info1 = FirmwareInfo(
device="/dev/serial/by-id/device1",
firmware_type=ApplicationType.SPINEL,
firmware_version=None,
source="otbr",
owners=[
AsyncMock(is_running=AsyncMock(return_value=False)),
AsyncMock(is_running=AsyncMock(return_value=False)),
],
)
mock_otbr_addon_manager = AsyncMock()
mock_multipan_addon_manager = AsyncMock()
# Second OTBR: fully running but is with an unrelated device
otbr_firmware_info2 = FirmwareInfo(
device="/dev/serial/by-id/device2", # An unrelated device
firmware_type=ApplicationType.SPINEL,
firmware_version=None,
source="otbr",
owners=[
AsyncMock(is_running=AsyncMock(return_value=True)),
AsyncMock(is_running=AsyncMock(return_value=True)),
],
)
with (
patch(
"homeassistant.components.homeassistant_hardware.util.is_hassio",
return_value=True,
"homeassistant.components.homeassistant_hardware.util.get_zha_firmware_info",
return_value=zha_firmware_info,
),
patch(
"homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager",
return_value=mock_otbr_addon_manager,
),
patch(
"homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager",
return_value=mock_multipan_addon_manager,
"homeassistant.components.homeassistant_hardware.util.get_otbr_firmware_info",
side_effect=lambda _, config_entry: {
otbr1: otbr_firmware_info1,
otbr2: otbr_firmware_info2,
}[config_entry],
),
):
mock_otbr_addon_manager.async_get_addon_info.side_effect = AddonError()
mock_multipan_addon_manager.async_get_addon_info.side_effect = AddonError()
# ZHA wins for the first stick, since it's actually running
assert (
await guess_firmware_info(hass, "/dev/serial/by-id/device1")
) == zha_firmware_info
# Hassio errors are ignored and we still go with ZHA
assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=True, firmware_type=ApplicationType.EZSP, source="zha"
)
# Second stick is communicating exclusively with the second OTBR
assert (
await guess_firmware_info(hass, "/dev/serial/by-id/device2")
) == otbr_firmware_info2
mock_otbr_addon_manager.async_get_addon_info.side_effect = None
mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo(
available=True,
hostname=None,
options={"device": "/some/other/device"},
state=AddonState.RUNNING,
update_available=False,
version="1.0.0",
)
# We will prefer ZHA, as it is running (and actually pointing to the device)
assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=True, firmware_type=ApplicationType.EZSP, source="zha"
)
mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo(
available=True,
hostname=None,
options={"device": path},
state=AddonState.NOT_RUNNING,
update_available=False,
version="1.0.0",
)
# We will still prefer ZHA, as it is the one actually running
assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=True, firmware_type=ApplicationType.EZSP, source="zha"
)
mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo(
available=True,
hostname=None,
options={"device": path},
state=AddonState.RUNNING,
update_available=False,
version="1.0.0",
)
# Finally, ZHA loses out to OTBR
assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=True, firmware_type=ApplicationType.SPINEL, source="otbr"
)
mock_multipan_addon_manager.async_get_addon_info.side_effect = None
mock_multipan_addon_manager.async_get_addon_info.return_value = AddonInfo(
available=True,
hostname=None,
options={"device": path},
state=AddonState.RUNNING,
update_available=False,
version="1.0.0",
)
# Which will lose out to multi-PAN
assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=True, firmware_type=ApplicationType.CPC, source="multiprotocol"
)
async def test_probe_silabs_firmware_type() -> None:
"""Test probing Silabs firmware type."""
with patch(
"homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type",
side_effect=RuntimeError,
):
assert (await probe_silabs_firmware_type("/dev/ttyUSB0")) is None
with patch(
"homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type",
side_effect=lambda self: setattr(self, "app_type", FlasherApplicationType.EZSP),
autospec=True,
) as mock_probe_app_type:
# The application type constant is converted back and forth transparently
result = await probe_silabs_firmware_type(
"/dev/ttyUSB0", probe_methods=[ApplicationType.EZSP]
)
assert result is ApplicationType.EZSP
flasher = mock_probe_app_type.mock_calls[0].args[0]
assert flasher._probe_methods == [FlasherApplicationType.EZSP]
# If we stop ZHA, OTBR will take priority
zha_firmware_info.owners[0].is_running.return_value = False
otbr_firmware_info1.owners[0].is_running.return_value = True
assert (
await guess_firmware_info(hass, "/dev/serial/by-id/device1")
) == otbr_firmware_info1

View File

@@ -4,7 +4,7 @@ from unittest.mock import patch
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareGuess,
FirmwareInfo,
)
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
from homeassistant.core import HomeAssistant
@@ -32,8 +32,8 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None:
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.homeassistant_sky_connect.guess_firmware_type",
return_value=FirmwareGuess(
"homeassistant.components.homeassistant_sky_connect.guess_firmware_info",
return_value=FirmwareInfo(
is_running=True,
firmware_type=ApplicationType.SPINEL,
source="otbr",

View File

@@ -8,7 +8,7 @@ from homeassistant.components import zha
from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareGuess,
FirmwareInfo,
)
from homeassistant.components.homeassistant_yellow.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
@@ -49,8 +49,8 @@ async def test_setup_entry(
return_value=onboarded,
),
patch(
"homeassistant.components.homeassistant_yellow.guess_firmware_type",
return_value=FirmwareGuess( # Nothing is setup
"homeassistant.components.homeassistant_yellow.guess_firmware_info",
return_value=FirmwareInfo( # Nothing is setup
is_running=False,
firmware_type=ApplicationType.EZSP,
source="unknown",