From 7c549870b5ea934bf439bf772d0db2b7c910df6a Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 1 Apr 2026 20:48:06 +0200 Subject: [PATCH] Add firmware update to Ubiquiti airOS (#166913) --- homeassistant/components/airos/__init__.py | 25 +- .../components/airos/binary_sensor.py | 2 +- homeassistant/components/airos/button.py | 4 +- homeassistant/components/airos/const.py | 1 + homeassistant/components/airos/coordinator.py | 111 +- homeassistant/components/airos/diagnostics.py | 12 +- homeassistant/components/airos/sensor.py | 2 +- homeassistant/components/airos/strings.json | 6 + homeassistant/components/airos/update.py | 101 ++ tests/components/airos/conftest.py | 28 +- .../fixtures/firmware_update_available.json | 9 + .../fixtures/firmware_update_latest.json | 3 + .../airos/snapshots/test_diagnostics.ambr | 1245 +++++++++-------- tests/components/airos/test_binary_sensor.py | 2 +- tests/components/airos/test_config_flow.py | 38 +- tests/components/airos/test_diagnostics.py | 5 +- tests/components/airos/test_init.py | 15 +- tests/components/airos/test_update.py | 146 ++ 18 files changed, 1066 insertions(+), 689 deletions(-) create mode 100644 homeassistant/components/airos/update.py create mode 100644 tests/components/airos/fixtures/firmware_update_available.json create mode 100644 tests/components/airos/fixtures/firmware_update_latest.json create mode 100644 tests/components/airos/test_update.py diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index a0e573f2f50..1942059b1e5 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -33,14 +33,21 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS -from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator +from .coordinator import ( + AirOSConfigEntry, + AirOSDataUpdateCoordinator, + AirOSFirmwareUpdateCoordinator, + AirOSRuntimeData, +) _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, + Platform.UPDATE, ] + _LOGGER = logging.getLogger(__name__) @@ -86,10 +93,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo airos_device = airos_class(**conn_data) - coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device) - await coordinator.async_config_entry_first_refresh() + data_coordinator = AirOSDataUpdateCoordinator( + hass, entry, device_data, airos_device + ) + await data_coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + firmware_coordinator: AirOSFirmwareUpdateCoordinator | None = None + if device_data["fw_major"] >= 8: + firmware_coordinator = AirOSFirmwareUpdateCoordinator(hass, entry, airos_device) + await firmware_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = AirOSRuntimeData( + status=data_coordinator, + firmware=firmware_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py index 0154db8dcb5..ced58410e9d 100644 --- a/homeassistant/components/airos/binary_sensor.py +++ b/homeassistant/components/airos/binary_sensor.py @@ -87,7 +87,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS binary sensors from a config entry.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.status entities = [ AirOSBinarySensor(coordinator, description) diff --git a/homeassistant/components/airos/button.py b/homeassistant/components/airos/button.py index 44eca04b9b6..1f60352947a 100644 --- a/homeassistant/components/airos/button.py +++ b/homeassistant/components/airos/button.py @@ -31,7 +31,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS button from a config entry.""" - async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)]) + async_add_entities( + [AirOSRebootButton(config_entry.runtime_data.status, REBOOT_BUTTON)] + ) class AirOSRebootButton(AirOSEntity, ButtonEntity): diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py index 548c4eff805..8e268a28d54 100644 --- a/homeassistant/components/airos/const.py +++ b/homeassistant/components/airos/const.py @@ -5,6 +5,7 @@ from datetime import timedelta DOMAIN = "airos" SCAN_INTERVAL = timedelta(minutes=1) +UPDATE_SCAN_INTERVAL = timedelta(days=1) MANUFACTURER = "Ubiquiti" diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py index 52ca88faebe..8748300b329 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -2,7 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import logging +from typing import Any, TypeVar from airos.airos6 import AirOS6, AirOS6Data from airos.airos8 import AirOS8, AirOS8Data @@ -19,20 +22,61 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL, UPDATE_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -AirOSDeviceDetect = AirOS8 | AirOS6 -AirOSDataDetect = AirOS8Data | AirOS6Data +type AirOSDeviceDetect = AirOS8 | AirOS6 +type AirOSDataDetect = AirOS8Data | AirOS6Data +type AirOSUpdateData = dict[str, Any] -type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] +type AirOSConfigEntry = ConfigEntry[AirOSRuntimeData] + +T = TypeVar("T", bound=AirOSDataDetect | AirOSUpdateData) + + +@dataclass +class AirOSRuntimeData: + """Data for AirOS config entry.""" + + status: AirOSDataUpdateCoordinator + firmware: AirOSFirmwareUpdateCoordinator | None + + +async def async_fetch_airos_data( + airos_device: AirOSDeviceDetect, + update_method: Callable[[], Awaitable[T]], +) -> T: + """Fetch data from AirOS device.""" + try: + await airos_device.login() + return await update_method() + except AirOSConnectionAuthenticationError as err: + _LOGGER.exception("Error authenticating with airOS device") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: + _LOGGER.error("Error connecting to airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except AirOSDataMissingError as err: + _LOGGER.error("Expected data not returned by airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_data_missing", + ) from err class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]): - """Class to manage fetching AirOS data from single endpoint.""" + """Class to manage fetching AirOS status data from single endpoint.""" - airos_device: AirOSDeviceDetect config_entry: AirOSConfigEntry def __init__( @@ -54,28 +98,33 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]): ) async def _async_update_data(self) -> AirOSDataDetect: - """Fetch data from AirOS.""" - try: - await self.airos_device.login() - return await self.airos_device.status() - except AirOSConnectionAuthenticationError as err: - _LOGGER.exception("Error authenticating with airOS device") - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="invalid_auth" - ) from err - except ( - AirOSConnectionSetupError, - AirOSDeviceConnectionError, - TimeoutError, - ) as err: - _LOGGER.error("Error connecting to airOS device: %s", err) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except AirOSDataMissingError as err: - _LOGGER.error("Expected data not returned by airOS device: %s", err) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="error_data_missing", - ) from err + """Fetch status data from AirOS.""" + return await async_fetch_airos_data(self.airos_device, self.airos_device.status) + + +class AirOSFirmwareUpdateCoordinator(DataUpdateCoordinator[AirOSUpdateData]): + """Class to manage fetching AirOS firmware.""" + + config_entry: AirOSConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + airos_device: AirOSDeviceDetect, + ) -> None: + """Initialize the coordinator.""" + self.airos_device = airos_device + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> AirOSUpdateData: + """Fetch firmware data from AirOS.""" + return await async_fetch_airos_data( + self.airos_device, self.airos_device.update_check + ) diff --git a/homeassistant/components/airos/diagnostics.py b/homeassistant/components/airos/diagnostics.py index 70fef685c86..4e006fedffd 100644 --- a/homeassistant/components/airos/diagnostics.py +++ b/homeassistant/components/airos/diagnostics.py @@ -29,5 +29,15 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" return { "entry_data": async_redact_data(entry.data, TO_REDACT_HA), - "data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS), + "data": { + "status_data": async_redact_data( + entry.runtime_data.status.data.to_dict(), TO_REDACT_AIROS + ), + "firmware_data": async_redact_data( + entry.runtime_data.firmware.data + if entry.runtime_data.firmware is not None + else {}, + TO_REDACT_AIROS, + ), + }, } diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 8b0673e241c..7b1b7a20b06 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -180,7 +180,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS sensors from a config entry.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.status entities = [AirOSSensor(coordinator, description) for description in COMMON_SENSORS] diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 56026eac552..fad6af5d58c 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -206,6 +206,12 @@ }, "reboot_failed": { "message": "The device did not accept the reboot request. Try again, or check your device web interface for errors." + }, + "update_connection_authentication_error": { + "message": "Authentication or connection failed during firmware update" + }, + "update_error": { + "message": "Connection failed during firmware update" } } } diff --git a/homeassistant/components/airos/update.py b/homeassistant/components/airos/update.py new file mode 100644 index 00000000000..fa79c9b01a1 --- /dev/null +++ b/homeassistant/components/airos/update.py @@ -0,0 +1,101 @@ +"""AirOS update component for Home Assistant.""" + +from __future__ import annotations + +import logging +from typing import Any + +from airos.exceptions import AirOSConnectionAuthenticationError, AirOSException + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + AirOSConfigEntry, + AirOSDataUpdateCoordinator, + AirOSFirmwareUpdateCoordinator, +) +from .entity import AirOSEntity + +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the AirOS update entity from a config entry.""" + runtime_data = config_entry.runtime_data + + if runtime_data.firmware is None: # Unsupported device + return + async_add_entities([AirOSUpdateEntity(runtime_data.status, runtime_data.firmware)]) + + +class AirOSUpdateEntity(AirOSEntity, UpdateEntity): + """Update entity for AirOS firmware updates.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + + def __init__( + self, + status: AirOSDataUpdateCoordinator, + firmware: AirOSFirmwareUpdateCoordinator, + ) -> None: + """Initialize the AirOS update entity.""" + super().__init__(status) + self.status = status + self.firmware = firmware + + self._attr_unique_id = f"{status.data.derived.mac}_firmware_update" + + @property + def installed_version(self) -> str | None: + """Return the installed firmware version.""" + return self.status.data.host.fwversion + + @property + def latest_version(self) -> str | None: + """Return the latest firmware version.""" + if not self.firmware.data.get("update", False): + return self.status.data.host.fwversion + return self.firmware.data.get("version") + + @property + def release_url(self) -> str | None: + """Return the release url of the latest firmware.""" + return self.firmware.data.get("changelog") + + async def async_install( + self, + version: str | None, + backup: bool, + **kwargs: Any, + ) -> None: + """Handle the firmware update installation.""" + _LOGGER.debug("Starting firmware update") + try: + await self.status.airos_device.login() + await self.status.airos_device.download() + await self.status.airos_device.install() + except AirOSConnectionAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_connection_authentication_error", + ) from err + except AirOSException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_error", + ) from err diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py index 1d47f111f08..f30b9b60617 100644 --- a/tests/components/airos/conftest.py +++ b/tests/components/airos/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Ubiquiti airOS tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from airos.airos6 import AirOS6Data @@ -9,6 +10,7 @@ from airos.helpers import DetectDeviceData import pytest from homeassistant.components.airos.const import DEFAULT_USERNAME, DOMAIN +from homeassistant.components.airos.coordinator import AirOSUpdateData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from . import AirOSData @@ -17,7 +19,16 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def ap_fixture(request: pytest.FixtureRequest) -> AirOSData: +def ap_firmware_fixture(request: pytest.FixtureRequest) -> AirOSUpdateData: + """Return fixture for AP firmware data.""" + available = getattr(request, "param", False) + if available: + return load_json_object_fixture("firmware_update_available.json", DOMAIN) + return load_json_object_fixture("firmware_update_latest.json", DOMAIN) + + +@pytest.fixture +def ap_status_fixture(request: pytest.FixtureRequest) -> AirOSData: """Load fixture data for airOS device.""" json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN) if hasattr(request, "param"): @@ -61,11 +72,14 @@ def mock_airos_class() -> Generator[MagicMock]: @pytest.fixture def mock_airos_client( - mock_airos_class: MagicMock, ap_fixture: AirOSData + mock_airos_class: MagicMock, + ap_status_fixture: AirOSData, + ap_firmware_fixture: dict[str, Any], ) -> Generator[AsyncMock]: """Fixture to mock the AirOS API client.""" client = mock_airos_class.return_value - client.status.return_value = ap_fixture + client.status.return_value = ap_status_fixture + client.update_check.return_value = ap_firmware_fixture client.login.return_value = True client.reboot.return_value = True return client @@ -97,13 +111,13 @@ def mock_discovery_method() -> Generator[AsyncMock]: @pytest.fixture -def mock_async_get_firmware_data(ap_fixture: AirOSData): +def mock_async_get_firmware_data(ap_status_fixture: AirOSData): """Fixture to mock async_get_firmware_data to not do a network call.""" - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) return_value = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) mock = AsyncMock(return_value=return_value) diff --git a/tests/components/airos/fixtures/firmware_update_available.json b/tests/components/airos/fixtures/firmware_update_available.json new file mode 100644 index 00000000000..d4faedbd2cd --- /dev/null +++ b/tests/components/airos/fixtures/firmware_update_available.json @@ -0,0 +1,9 @@ +{ + "checksum": "95915b6f040fedd05033f514427e99a1", + "version": "v8.7.22", + "security": "", + "date": "260227", + "url": "https://dl.ubnt.com/firmwares/XC-fw/V8.7.22/WA.v8.7.22.48486.260227.1959.bin", + "update": true, + "changelog": "https://dl.ubnt.com/firmwares/XC-fw/V8.7.22/changelog.txt" +} diff --git a/tests/components/airos/fixtures/firmware_update_latest.json b/tests/components/airos/fixtures/firmware_update_latest.json new file mode 100644 index 00000000000..a99ca57496a --- /dev/null +++ b/tests/components/airos/fixtures/firmware_update_latest.json @@ -0,0 +1,3 @@ +{ + "update": false +} diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index b1bed6741cf..c2079c8247e 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -2,635 +2,640 @@ # name: test_diagnostics dict({ 'data': dict({ - 'chain_names': list([ - dict({ - 'name': 'Chain 0', - 'number': 1, - }), - dict({ - 'name': 'Chain 1', - 'number': 2, - }), - ]), - 'derived': dict({ - 'access_point': True, - 'fw_major': 8, - 'mac': '**REDACTED**', - 'mac_interface': 'br0', - 'mode': 'point_to_point', - 'ptmp': False, - 'ptp': True, - 'role': 'access_point', - 'sku': 'Loco5AC', - 'station': False, + 'firmware_data': dict({ + 'update': False, }), - 'firewall': dict({ - 'eb6tables': False, - 'ebtables': False, - 'ip6tables': False, - 'iptables': False, - }), - 'genuine': '/images/genuine.png', - 'gps': dict({ - 'alt': None, - 'dim': None, - 'dop': None, - 'fix': 0, - 'lat': '**REDACTED**', - 'lon': '**REDACTED**', - 'sats': None, - 'time_synced': None, - }), - 'host': dict({ - 'cpuload': 10.10101, - 'device_id': '03aa0d0b40fed0a47088293584ef5432', - 'devmodel': 'NanoStation 5AC loco', - 'freeram': 16564224, - 'fwversion': 'v8.7.17', - 'height': 3, - 'hostname': '**REDACTED**', - 'loadavg': 0.412598, - 'netrole': 'bridge', - 'power_time': 268683, - 'temperature': 0, - 'time': '2025-06-23 23:06:42', - 'timestamp': 2668313184, - 'totalram': 63447040, - 'uptime': 264888, - }), - 'interfaces': list([ - dict({ - 'enabled': True, - 'hwaddr': '**REDACTED**', - 'ifname': 'eth0', - 'mtu': 1500, - 'status': dict({ - 'cable_len': 18, - 'duplex': True, - 'ip6addr': None, - 'ipaddr': '**REDACTED**', - 'plugged': True, - 'rx_bytes': 3984971949, - 'rx_dropped': 0, - 'rx_errors': 4, - 'rx_packets': 73564835, - 'snr': list([ - 30, - 30, - 30, - 30, - ]), - 'speed': 1000, - 'tx_bytes': 209900085624, - 'tx_dropped': 10, - 'tx_errors': 0, - 'tx_packets': 185866883, - }), - }), - dict({ - 'enabled': True, - 'hwaddr': '**REDACTED**', - 'ifname': 'ath0', - 'mtu': 1500, - 'status': dict({ - 'cable_len': None, - 'duplex': False, - 'ip6addr': None, - 'ipaddr': '**REDACTED**', - 'plugged': False, - 'rx_bytes': 206938324766, - 'rx_dropped': 0, - 'rx_errors': 0, - 'rx_packets': 149767200, - 'snr': None, - 'speed': 0, - 'tx_bytes': 5265602738, - 'tx_dropped': 2005, - 'tx_errors': 0, - 'tx_packets': 52980390, - }), - }), - dict({ - 'enabled': True, - 'hwaddr': '**REDACTED**', - 'ifname': 'br0', - 'mtu': 1500, - 'status': dict({ - 'cable_len': None, - 'duplex': False, - 'ip6addr': '**REDACTED**', - 'ipaddr': '**REDACTED**', - 'plugged': True, - 'rx_bytes': 204802727, - 'rx_dropped': 0, - 'rx_errors': 0, - 'rx_packets': 1791592, - 'snr': None, - 'speed': 0, - 'tx_bytes': 236295176, - 'tx_dropped': 0, - 'tx_errors': 0, - 'tx_packets': 298119, - }), - }), - ]), - 'ntpclient': dict({ - }), - 'portfw': False, - 'provmode': dict({ - }), - 'services': dict({ - 'airview': 2, - 'dhcp6d_stateful': False, - 'dhcpc': False, - 'dhcpd': False, - 'pppoe': False, - }), - 'unms': dict({ - 'status': 0, - 'timestamp': None, - }), - 'wireless': dict({ - 'antenna_gain': 13, - 'apmac': '**REDACTED**', - 'aprepeater': False, - 'band': 2, - 'cac_state': 0, - 'cac_timeout': 0, - 'center1_freq': 5530, - 'chanbw': 80, - 'compat_11n': 0, - 'count': 1, - 'dfs': 1, - 'distance': 0, - 'essid': '**REDACTED**', - 'frequency': 5500, - 'hide_essid': 0, - 'ieeemode': '11ACVHT80', - 'mode': 'ap-ptp', - 'noisef': -89, - 'nol_state': 0, - 'nol_timeout': 0, - 'polling': dict({ - 'atpc_status': 2, - 'cb_capacity': 593970, - 'dl_capacity': 647400, - 'ff_cap_rep': False, - 'fixed_frame': False, - 'flex_mode': None, - 'gps_sync': False, - 'rx_use': 42, - 'tx_use': 6, - 'ul_capacity': 540540, - 'use': 48, - }), - 'rstatus': 5, - 'rx_chainmask': 3, - 'rx_idx': 8, - 'rx_nss': 2, - 'security': 'WPA2', - 'service': dict({ - 'link': 266003, - 'time': 267181, - }), - 'sta': list([ + 'status_data': dict({ + 'chain_names': list([ dict({ - 'airmax': dict({ - 'actual_priority': 0, - 'atpc_status': 2, - 'beam': 0, - 'cb_capacity': 593970, - 'desired_priority': 0, - 'dl_capacity': 647400, - 'rx': dict({ - 'cinr': 31, - 'evm': list([ - list([ - 31, - 28, - 33, - 32, - 32, - 32, - 31, - 31, - 31, - 29, - 30, - 32, - 30, - 27, - 34, - 31, - 31, - 30, - 32, - 29, - 31, - 29, - 31, - 33, - 31, - 31, - 32, - 30, - 31, - 34, - 33, - 31, - 30, - 31, - 30, - 31, - 31, - 32, - 31, - 30, - 33, - 31, - 30, - 31, - 27, - 31, - 30, - 30, - 30, - 30, - 30, - 29, - 32, - 34, - 31, - 30, - 28, - 30, - 29, - 35, - 31, - 33, - 32, - 29, - ]), - list([ - 34, - 34, - 35, - 34, - 35, - 35, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 35, - 35, - 34, - 34, - 35, - 34, - 33, - 33, - 35, - 34, - 34, - 35, - 34, - 35, - 34, - 34, - 35, - 34, - 34, - 33, - 34, - 34, - 34, - 34, - 34, - 35, - 35, - 35, - 34, - 35, - 33, - 34, - 34, - 34, - 34, - 35, - 35, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 35, - 35, - ]), - ]), - 'usage': 42, - }), - 'tx': dict({ - 'cinr': 31, - 'evm': list([ - list([ - 32, - 34, - 28, - 33, - 35, - 30, - 31, - 33, - 30, - 30, - 32, - 30, - 29, - 33, - 31, - 29, - 33, - 31, - 31, - 30, - 33, - 34, - 33, - 31, - 33, - 32, - 32, - 31, - 29, - 31, - 30, - 32, - 31, - 30, - 29, - 32, - 31, - 32, - 31, - 31, - 32, - 29, - 31, - 29, - 30, - 32, - 32, - 31, - 32, - 32, - 33, - 31, - 28, - 29, - 31, - 31, - 33, - 32, - 33, - 32, - 32, - 32, - 31, - 33, - ]), - list([ - 37, - 37, - 37, - 38, - 38, - 37, - 36, - 38, - 38, - 37, - 37, - 37, - 37, - 37, - 39, - 37, - 37, - 37, - 37, - 37, - 37, - 36, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 38, - 37, - 37, - 38, - 37, - 37, - 37, - 38, - 37, - 38, - 37, - 37, - 37, - 37, - 37, - 36, - 37, - 37, - 37, - 37, - 37, - 37, - 38, - 37, - 37, - 38, - 37, - 36, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - ]), - ]), - 'usage': 6, - }), - 'ul_capacity': 540540, + 'name': 'Chain 0', + 'number': 1, + }), + dict({ + 'name': 'Chain 1', + 'number': 2, + }), + ]), + 'derived': dict({ + 'access_point': True, + 'fw_major': 8, + 'mac': '**REDACTED**', + 'mac_interface': 'br0', + 'mode': 'point_to_point', + 'ptmp': False, + 'ptp': True, + 'role': 'access_point', + 'sku': 'Loco5AC', + 'station': False, + }), + 'firewall': dict({ + 'eb6tables': False, + 'ebtables': False, + 'ip6tables': False, + 'iptables': False, + }), + 'genuine': '/images/genuine.png', + 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, + }), + 'host': dict({ + 'cpuload': 10.10101, + 'device_id': '03aa0d0b40fed0a47088293584ef5432', + 'devmodel': 'NanoStation 5AC loco', + 'freeram': 16564224, + 'fwversion': 'v8.7.17', + 'height': 3, + 'hostname': '**REDACTED**', + 'loadavg': 0.412598, + 'netrole': 'bridge', + 'power_time': 268683, + 'temperature': 0, + 'time': '2025-06-23 23:06:42', + 'timestamp': 2668313184, + 'totalram': 63447040, + 'uptime': 264888, + }), + 'interfaces': list([ + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'eth0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': 18, + 'duplex': True, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': True, + 'rx_bytes': 3984971949, + 'rx_dropped': 0, + 'rx_errors': 4, + 'rx_packets': 73564835, + 'snr': list([ + 30, + 30, + 30, + 30, + ]), + 'speed': 1000, + 'tx_bytes': 209900085624, + 'tx_dropped': 10, + 'tx_errors': 0, + 'tx_packets': 185866883, }), - 'airos_connected': True, - 'cb_capacity_expect': 416000, - 'chainrssi': list([ - 35, - 32, - 0, - ]), - 'distance': 1, - 'dl_avg_linkscore': 100, - 'dl_capacity_expect': 208000, - 'dl_linkscore': 100, - 'dl_rate_expect': 3, - 'dl_signal_expect': -80, - 'last_disc': 1, - 'lastip': '**REDACTED**', - 'mac': '**REDACTED**', - 'noisefloor': -89, - 'remote': dict({ - 'age': 1, - 'airview': 2, - 'antenna_gain': 13, - 'cable_loss': 0, - 'chainrssi': list([ - 33, - 37, - 0, - ]), - 'compat_11n': 0, - 'cpuload': 43.564301, - 'device_id': 'd4f4cdf82961e619328a8f72f8d7653b', - 'distance': 1, - 'ethlist': list([ - dict({ - 'cable_len': 14, - 'duplex': True, - 'enabled': True, - 'ifname': 'eth0', - 'plugged': True, - 'snr': list([ - 30, - 30, - 29, - 30, - ]), - 'speed': 1000, - }), - ]), - 'freeram': 14290944, - 'gps': dict({ - 'alt': None, - 'dim': None, - 'dop': None, - 'fix': 0, - 'lat': '**REDACTED**', - 'lon': '**REDACTED**', - 'sats': None, - 'time_synced': None, - }), - 'height': 2, - 'hostname': '**REDACTED**', + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'ath0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': False, + 'rx_bytes': 206938324766, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 149767200, + 'snr': None, + 'speed': 0, + 'tx_bytes': 5265602738, + 'tx_dropped': 2005, + 'tx_errors': 0, + 'tx_packets': 52980390, + }), + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'br0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, 'ip6addr': '**REDACTED**', 'ipaddr': '**REDACTED**', - 'mode': 'sta-ptp', - 'netrole': 'bridge', - 'noisefloor': -90, - 'oob': False, - 'platform': 'NanoStation 5AC loco', - 'power_time': 268512, - 'rssi': 38, - 'rx_bytes': 3624206478, - 'rx_chainmask': 3, - 'rx_throughput': 251, - 'service': dict({ - 'link': 265996, - 'time': 267195, - }), - 'signal': -58, - 'sys_id': '0xe7fa', - 'temperature': 0, - 'time': '2025-06-23 23:13:54', - 'totalram': 63447040, - 'tx_bytes': 212308148210, - 'tx_power': -4, - 'tx_ratedata': list([ - 14, - 4, - 372, - 2223, - 4708, - 4037, - 8142, - 485763, - 29420892, - 24748154, - ]), - 'tx_throughput': 16023, - 'unms': dict({ - 'status': 0, - 'timestamp': None, - }), - 'uptime': 265320, - 'version': 'WA.ar934x.v8.7.17.48152.250620.2132', + 'plugged': True, + 'rx_bytes': 204802727, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 1791592, + 'snr': None, + 'speed': 0, + 'tx_bytes': 236295176, + 'tx_dropped': 0, + 'tx_errors': 0, + 'tx_packets': 298119, }), - 'rssi': 37, - 'rx_idx': 8, - 'rx_nss': 2, - 'signal': -59, - 'stats': dict({ - 'rx_bytes': 206938324814, - 'rx_packets': 149767200, - 'rx_pps': 846, - 'tx_bytes': 5265602739, - 'tx_packets': 52980390, - 'tx_pps': 0, - }), - 'tx_idx': 9, - 'tx_latency': 0, - 'tx_lretries': 0, - 'tx_nss': 2, - 'tx_packets': 0, - 'tx_ratedata': list([ - 175, - 4, - 47, - 200, - 673, - 158, - 163, - 138, - 68895, - 19577430, - ]), - 'tx_sretries': 0, - 'ul_avg_linkscore': 88, - 'ul_capacity_expect': 624000, - 'ul_linkscore': 86, - 'ul_rate_expect': 8, - 'ul_signal_expect': -55, - 'uptime': 170281, }), ]), - 'sta_disconnected': list([ - ]), - 'throughput': dict({ - 'rx': 9907, - 'tx': 222, + 'ntpclient': dict({ + }), + 'portfw': False, + 'provmode': dict({ + }), + 'services': dict({ + 'airview': 2, + 'dhcp6d_stateful': False, + 'dhcpc': False, + 'dhcpd': False, + 'pppoe': False, + }), + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'wireless': dict({ + 'antenna_gain': 13, + 'apmac': '**REDACTED**', + 'aprepeater': False, + 'band': 2, + 'cac_state': 0, + 'cac_timeout': 0, + 'center1_freq': 5530, + 'chanbw': 80, + 'compat_11n': 0, + 'count': 1, + 'dfs': 1, + 'distance': 0, + 'essid': '**REDACTED**', + 'frequency': 5500, + 'hide_essid': 0, + 'ieeemode': '11ACVHT80', + 'mode': 'ap-ptp', + 'noisef': -89, + 'nol_state': 0, + 'nol_timeout': 0, + 'polling': dict({ + 'atpc_status': 2, + 'cb_capacity': 593970, + 'dl_capacity': 647400, + 'ff_cap_rep': False, + 'fixed_frame': False, + 'flex_mode': None, + 'gps_sync': False, + 'rx_use': 42, + 'tx_use': 6, + 'ul_capacity': 540540, + 'use': 48, + }), + 'rstatus': 5, + 'rx_chainmask': 3, + 'rx_idx': 8, + 'rx_nss': 2, + 'security': 'WPA2', + 'service': dict({ + 'link': 266003, + 'time': 267181, + }), + 'sta': list([ + dict({ + 'airmax': dict({ + 'actual_priority': 0, + 'atpc_status': 2, + 'beam': 0, + 'cb_capacity': 593970, + 'desired_priority': 0, + 'dl_capacity': 647400, + 'rx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 31, + 28, + 33, + 32, + 32, + 32, + 31, + 31, + 31, + 29, + 30, + 32, + 30, + 27, + 34, + 31, + 31, + 30, + 32, + 29, + 31, + 29, + 31, + 33, + 31, + 31, + 32, + 30, + 31, + 34, + 33, + 31, + 30, + 31, + 30, + 31, + 31, + 32, + 31, + 30, + 33, + 31, + 30, + 31, + 27, + 31, + 30, + 30, + 30, + 30, + 30, + 29, + 32, + 34, + 31, + 30, + 28, + 30, + 29, + 35, + 31, + 33, + 32, + 29, + ]), + list([ + 34, + 34, + 35, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 35, + 34, + 33, + 33, + 35, + 34, + 34, + 35, + 34, + 35, + 34, + 34, + 35, + 34, + 34, + 33, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 35, + 34, + 35, + 33, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + ]), + ]), + 'usage': 42, + }), + 'tx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 32, + 34, + 28, + 33, + 35, + 30, + 31, + 33, + 30, + 30, + 32, + 30, + 29, + 33, + 31, + 29, + 33, + 31, + 31, + 30, + 33, + 34, + 33, + 31, + 33, + 32, + 32, + 31, + 29, + 31, + 30, + 32, + 31, + 30, + 29, + 32, + 31, + 32, + 31, + 31, + 32, + 29, + 31, + 29, + 30, + 32, + 32, + 31, + 32, + 32, + 33, + 31, + 28, + 29, + 31, + 31, + 33, + 32, + 33, + 32, + 32, + 32, + 31, + 33, + ]), + list([ + 37, + 37, + 37, + 38, + 38, + 37, + 36, + 38, + 38, + 37, + 37, + 37, + 37, + 37, + 39, + 37, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 37, + 37, + 38, + 37, + 38, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + ]), + ]), + 'usage': 6, + }), + 'ul_capacity': 540540, + }), + 'airos_connected': True, + 'cb_capacity_expect': 416000, + 'chainrssi': list([ + 35, + 32, + 0, + ]), + 'distance': 1, + 'dl_avg_linkscore': 100, + 'dl_capacity_expect': 208000, + 'dl_linkscore': 100, + 'dl_rate_expect': 3, + 'dl_signal_expect': -80, + 'last_disc': 1, + 'lastip': '**REDACTED**', + 'mac': '**REDACTED**', + 'noisefloor': -89, + 'remote': dict({ + 'age': 1, + 'airview': 2, + 'antenna_gain': 13, + 'cable_loss': 0, + 'chainrssi': list([ + 33, + 37, + 0, + ]), + 'compat_11n': 0, + 'cpuload': 43.564301, + 'device_id': 'd4f4cdf82961e619328a8f72f8d7653b', + 'distance': 1, + 'ethlist': list([ + dict({ + 'cable_len': 14, + 'duplex': True, + 'enabled': True, + 'ifname': 'eth0', + 'plugged': True, + 'snr': list([ + 30, + 30, + 29, + 30, + ]), + 'speed': 1000, + }), + ]), + 'freeram': 14290944, + 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, + }), + 'height': 2, + 'hostname': '**REDACTED**', + 'ip6addr': '**REDACTED**', + 'ipaddr': '**REDACTED**', + 'mode': 'sta-ptp', + 'netrole': 'bridge', + 'noisefloor': -90, + 'oob': False, + 'platform': 'NanoStation 5AC loco', + 'power_time': 268512, + 'rssi': 38, + 'rx_bytes': 3624206478, + 'rx_chainmask': 3, + 'rx_throughput': 251, + 'service': dict({ + 'link': 265996, + 'time': 267195, + }), + 'signal': -58, + 'sys_id': '0xe7fa', + 'temperature': 0, + 'time': '2025-06-23 23:13:54', + 'totalram': 63447040, + 'tx_bytes': 212308148210, + 'tx_power': -4, + 'tx_ratedata': list([ + 14, + 4, + 372, + 2223, + 4708, + 4037, + 8142, + 485763, + 29420892, + 24748154, + ]), + 'tx_throughput': 16023, + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'uptime': 265320, + 'version': 'WA.ar934x.v8.7.17.48152.250620.2132', + }), + 'rssi': 37, + 'rx_idx': 8, + 'rx_nss': 2, + 'signal': -59, + 'stats': dict({ + 'rx_bytes': 206938324814, + 'rx_packets': 149767200, + 'rx_pps': 846, + 'tx_bytes': 5265602739, + 'tx_packets': 52980390, + 'tx_pps': 0, + }), + 'tx_idx': 9, + 'tx_latency': 0, + 'tx_lretries': 0, + 'tx_nss': 2, + 'tx_packets': 0, + 'tx_ratedata': list([ + 175, + 4, + 47, + 200, + 673, + 158, + 163, + 138, + 68895, + 19577430, + ]), + 'tx_sretries': 0, + 'ul_avg_linkscore': 88, + 'ul_capacity_expect': 624000, + 'ul_linkscore': 86, + 'ul_rate_expect': 8, + 'ul_signal_expect': -55, + 'uptime': 170281, + }), + ]), + 'sta_disconnected': list([ + ]), + 'throughput': dict({ + 'rx': 9907, + 'tx': 222, + }), + 'tx_chainmask': 3, + 'tx_idx': 9, + 'tx_nss': 2, + 'txpower': -3, }), - 'tx_chainmask': 3, - 'tx_idx': 9, - 'tx_nss': 2, - 'txpower': -3, }), }), 'entry_data': dict({ diff --git a/tests/components/airos/test_binary_sensor.py b/tests/components/airos/test_binary_sensor.py index 85aa771a53a..6c1d2800830 100644 --- a/tests/components/airos/test_binary_sensor.py +++ b/tests/components/airos/test_binary_sensor.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("ap_fixture"), + ("ap_status_fixture"), [ "airos_loco5ac_ap-ptp.json", # v8 ptp "airos_liteapgps_ap_ptmp_40mhz.json", # v8 ptmp diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 8ed8ca3ac35..d3d4af7142a 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -84,7 +84,7 @@ MOCK_DISC_EXISTS = { async def test_manual_flow_creates_entry( hass: HomeAssistant, - ap_fixture: dict[str, Any], + ap_status_fixture: dict[str, Any], mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, mock_setup_entry: AsyncMock, @@ -159,7 +159,7 @@ async def test_form_duplicate_entry( async def test_form_exception_handling( hass: HomeAssistant, mock_setup_entry: AsyncMock, - ap_fixture: dict[str, Any], + ap_status_fixture: dict[str, Any], mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, exception: Exception, @@ -186,11 +186,11 @@ async def test_form_exception_handling( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) with patch( @@ -210,7 +210,7 @@ async def test_form_exception_handling( async def test_reauth_flow_scenario( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, mock_airos_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock, @@ -234,11 +234,11 @@ async def test_reauth_flow_scenario( assert flow["type"] == FlowResultType.FORM assert flow["step_id"] == REAUTH_STEP - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) mock_firmware = AsyncMock(return_value=valid_data) @@ -283,7 +283,7 @@ async def test_reauth_flow_scenario( ) async def test_reauth_flow_scenarios( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, expected_error: str, mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, @@ -321,11 +321,11 @@ async def test_reauth_flow_scenarios( assert result["step_id"] == REAUTH_STEP assert result["errors"] == {"base": expected_error} - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) with patch( @@ -346,7 +346,7 @@ async def test_reauth_flow_scenarios( async def test_reauth_unique_id_mismatch( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, mock_config_entry: MockConfigEntry, @@ -366,11 +366,11 @@ async def test_reauth_unique_id_mismatch( data=mock_config_entry.data, ) - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, mac="FF:23:45:67:89:AB", - hostname=ap_fixture.host.hostname, + hostname=ap_status_fixture.host.hostname, ) with patch( @@ -501,7 +501,7 @@ async def test_reconfigure_flow_failure( async def test_reconfigure_unique_id_mismatch( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, mock_config_entry: MockConfigEntry, @@ -516,11 +516,11 @@ async def test_reconfigure_unique_id_mismatch( ) flow_id = result["flow_id"] - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) mismatched_data = DetectDeviceData( fw_major=fw_major, mac="FF:23:45:67:89:AB", - hostname=ap_fixture.host.hostname, + hostname=ap_status_fixture.host.hostname, ) user_input = { diff --git a/tests/components/airos/test_diagnostics.py b/tests/components/airos/test_diagnostics.py index f6b8733f880..99f5297c022 100644 --- a/tests/components/airos/test_diagnostics.py +++ b/tests/components/airos/test_diagnostics.py @@ -11,7 +11,7 @@ from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator +from tests.typing import Any, ClientSessionGenerator async def test_diagnostics( @@ -19,7 +19,8 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, mock_airos_client: MagicMock, mock_config_entry: MockConfigEntry, - ap_fixture: AirOS8Data, + ap_status_fixture: AirOS8Data, + ap_firmware_fixture: dict[str, Any], snapshot: SnapshotAssertion, mock_async_get_firmware_data: AsyncMock, ) -> None: diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py index 2b06fb3dc80..77da2ddffc6 100644 --- a/tests/components/airos/test_init.py +++ b/tests/components/airos/test_init.py @@ -18,9 +18,14 @@ from homeassistant.components.airos.const import ( DOMAIN, SECTION_ADVANCED_SETTINGS, ) +from homeassistant.components.airos.coordinator import async_fetch_airos_data from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntryAuthFailed, + ConfigEntryState, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -282,3 +287,11 @@ async def test_setup_entry_failure( result = await hass.config_entries.async_setup(mock_config_entry.entry_id) assert result is False assert mock_config_entry.state == state + + +async def test_fetch_airos_data_auth_error(mock_airos_client: MagicMock) -> None: + """Test login auth error triggers ConfigEntryAuthFailed.""" + mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError + + with pytest.raises(ConfigEntryAuthFailed): + await async_fetch_airos_data(mock_airos_client, mock_airos_client.status) diff --git a/tests/components/airos/test_update.py b/tests/components/airos/test_update.py new file mode 100644 index 00000000000..b162258e68f --- /dev/null +++ b/tests/components/airos/test_update.py @@ -0,0 +1,146 @@ +"""Test the Ubiquiti airOS firmware update.""" + +from unittest.mock import AsyncMock + +from airos.exceptions import ( + AirOSConnectionAuthenticationError, + AirOSDeviceConnectionError, + AirOSException, +) +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("ap_status_fixture", "ap_firmware_fixture", "entity_id"), + [ + ("airos_loco5ac_ap-ptp.json", True, "update.nanostation_5ac_ap_name_firmware"), + ("airos_liteapgps_ap_ptmp_40mhz.json", True, "update.house_bridge_firmware"), + ], + indirect=["ap_status_fixture", "ap_firmware_fixture"], +) +async def test_update_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_airos_client: AsyncMock, + mock_async_get_firmware_data: AsyncMock, + entity_id: str, +) -> None: + """Test the firmware update entity behavior.""" + await setup_integration(hass, mock_config_entry, [Platform.UPDATE]) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + update_entities = [e for e in entries if e.domain == "update"] + + assert len(update_entities) == 1 + assert update_entities[0].entity_id == entity_id + + state = hass.states.get(entity_id) + assert state is not None + + await hass.services.async_call( + "update", + "install", + {"entity_id": entity_id}, + blocking=True, + ) + + mock_airos_client.update_check.assert_awaited() + mock_airos_client.download.assert_awaited() + mock_airos_client.install.assert_awaited() + + await hass.async_block_till_done() + new_state = hass.states.get(entity_id) + assert new_state is not None + + +@pytest.mark.parametrize( + "ap_status_fixture", + [ + "airos_NanoStation_loco_M5_v6.3.16_XM_sta.json", + "airos_NanoStation_M5_sta_v6.3.16.json", + ], + indirect=True, +) +async def test_no_update_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_airos_client: AsyncMock, + mock_async_get_firmware_data: AsyncMock, + ap_firmware_fixture: dict[str, bool], +) -> None: + """Test the firmware update entity behavior is not implemented.""" + await setup_integration(hass, mock_config_entry, [Platform.UPDATE]) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + update_entities = [e for e in entries if e.domain == "update"] + + assert not update_entities + + +@pytest.mark.parametrize( + ("ap_status_fixture", "ap_firmware_fixture", "exception", "translation_key"), + [ + ( + "airos_loco5ac_ap-ptp.json", + True, + AirOSConnectionAuthenticationError, + "update_connection_authentication_error", + ), + ( + "airos_liteapgps_ap_ptmp_40mhz.json", + True, + AirOSDeviceConnectionError, + "update_error", + ), + ], + indirect=["ap_status_fixture", "ap_firmware_fixture"], +) +async def test_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_airos_client: AsyncMock, + mock_async_get_firmware_data: AsyncMock, + exception: AirOSException, + translation_key: str, +) -> None: + """Test the firmware update entity behavior.""" + await setup_integration(hass, mock_config_entry, [Platform.UPDATE]) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + update_entities = [e for e in entries if e.domain == "update"] + + assert len(update_entities) == 1 + entity_id = update_entities[0].entity_id + + state = hass.states.get(entity_id) + assert state is not None + + mock_airos_client.download.side_effect = exception + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + "update", + "install", + {"entity_id": entity_id}, + blocking=True, + ) + + assert exc.value.translation_key == translation_key