From 988f7c18e6d245b845550c8c3105dc66e47f263f Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 3 Oct 2025 10:44:41 +0200 Subject: [PATCH 01/18] Align with core --- README.md | 52 +-- README_BEFORE.md | 61 +++ custom_components/airos/__init__.py | 48 ++- custom_components/airos/binary_sensor.py | 74 +--- .../airos/{button.py => button.py.wip} | 0 custom_components/airos/config_flow.py | 170 ++++++--- custom_components/airos/const.py | 8 +- custom_components/airos/coordinator.py | 30 +- custom_components/airos/diagnostics.py | 1 - custom_components/airos/entity.py | 14 +- .../airos/{helpers.py => helpers.py.wip} | 0 custom_components/airos/manifest.json | 11 +- custom_components/airos/quality_scale.yaml | 70 ++++ custom_components/airos/sensor.py | 67 +++- custom_components/airos/strings.json | 57 ++- custom_components/airos/translations/en.json | 47 ++- hacs.json | 4 +- pyproject.toml | 2 +- scripts/core-testing.sh | 3 +- tests/components/airos/__init__.py | 16 +- tests/components/airos/conftest.py | 45 ++- .../airos/fixtures/airos_loco5ac_ap-ptp.json | 354 ++++++++++++++++++ .../airos/snapshots/test_binary_sensor.ambr | 245 ++++++++++++ .../airos/snapshots/test_diagnostics.ambr | 32 +- .../airos/snapshots/test_sensor.ambr | 339 +++++++++++++++-- tests/components/airos/test_binary_sensor.py | 19 +- tests/components/airos/test_config_flow.py | 200 ++++++++-- tests/components/airos/test_coordinator.py | 56 --- tests/components/airos/test_diagnostics.py | 4 +- tests/components/airos/test_init.py | 169 +++++++++ tests/components/airos/test_sensor.py | 56 ++- 31 files changed, 1862 insertions(+), 392 deletions(-) create mode 100644 README_BEFORE.md rename custom_components/airos/{button.py => button.py.wip} (100%) rename custom_components/airos/{helpers.py => helpers.py.wip} (100%) create mode 100644 custom_components/airos/quality_scale.yaml create mode 100644 tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json create mode 100644 tests/components/airos/snapshots/test_binary_sensor.ambr delete mode 100644 tests/components/airos/test_coordinator.py create mode 100644 tests/components/airos/test_init.py diff --git a/README.md b/README.md index 11d3677..c7510ec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# Ubiquiti airOS Custom Component for Home Assistant +# Ubiquiti airOS Custom TESTING Component for Home Assistant + +**:warning::warning:Only -temporarily- install this component if needed, airOS is a HA Core component - this repo is just for testing things :warning::warning::warning** **:warning::warning::warning:Read the [release notes](https://github.com/CoMPaTech/hAirOS/releases) before upgrading, in case there are BREAKING changes! :warning: :warning: :warning:** -[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/CoMPaTech/hAirOS/) +[![Maintenance](https://img.shields.io/badge/Maintained%3F-no-red.svg)](https://github.com/CoMPaTech/hAirOS/) [![CodeFactor](https://www.codefactor.io/repository/github/CoMPaTech/hAirOS/badge)](https://www.codefactor.io/repository/github/CoMPaTech/hAirOS) [![HASSfest](https://github.com/CoMPaTech/hAirOS/workflows/Validate%20with%20hassfest/badge.svg)](https://github.com/CoMPaTech/hAirOS/actions) [![Generic badge](https://img.shields.io/github/v/release/CoMPaTech/hAirOS)](https://github.com/CoMPaTech/hAirOS) @@ -12,50 +14,8 @@ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=CoMPaTech_hAirOS&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=CoMPaTech_hAirOS) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=CoMPaTech_hAirOS&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=CoMPaTech_hAirOS) -## Requirements - -Only tested/confirmed with airOS 8 on: - -- [x] Nanostation 5AC (LOCO5AC) by @CoMPaTech -- [x] PowerBeam 5AC gen2 by @exico91 - -## Installation - -[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=CoMPaTech&repository=hAirOShAirOhAirOSntegrations) - -## Configuration - -Configure this integration the usual way, requiring your username (`ubnt`), password and IP address of the airOS device. - -## What it provides - -In the current state it retrieves some information and should display the 'other device connected', connection mode, SSID and both actual data being transferred and the maximum capacity. These are displayed as `sensor`s or `binary_sensor`s though most `binary_sensor`s are disabled by default. Additionally for stations connected, child-devices are displayed with both a `binary_sensor` indicating connection and a `button` to force reconnect on the connected device (i.e. the same as the reconnect button on the default homepage of airOS +## More/older information -## State: BETA - -Even though available does not mean it's stable yet, the HA part is solid but the class used to interact with the API is in need of improvement (e.g. better overall handling). This might also warrant having the class available as a module from pypi. - -## How to install? - -- Use [HACS](https://hacs.xyz) -- Navigate to the `Integrations` page and use the three-dots icon on the top right to add a custom repository. -- Use the link to this page as the URL and select 'Integrations' as the category. -- Look for `airOS` in `Integrations` and install it! - -### How to add the integration to HA Core - -For each device you will have to add it as an integration. - -- [ ] In Home Assistant click on `Configuration` -- [ ] Click on `Integrations` -- [ ] Hit the `+` button in the right lower corner -- [ ] Search or browse for 'Ubiquiti airOS' and click it -- [ ] Enter your details - -### Is it tested? - -It works on my bike and Home Assistant installation :) Let me know if it works on yours! +See the README_BEFORE.md in this repository [![SonarCloud](https://sonarcloud.io/images/project_badges/sonarcloud-black.svg)](https://sonarcloud.io/summary/new_code?id=CoMPaTech_hAirOS) - -And [Home-Assistant Hassfest](https://github.com/home-assistant/actions) and [HACS validation](https://github.com/hacs/action) diff --git a/README_BEFORE.md b/README_BEFORE.md new file mode 100644 index 0000000..e1c5652 --- /dev/null +++ b/README_BEFORE.md @@ -0,0 +1,61 @@ +# Unmaintained README + +**:warning::warning::warning:Read the [release notes](https://github.com/CoMPaTech/hAirOS/releases) before upgrading, in case there are BREAKING changes! :warning: :warning: :warning:** + +[![Maintenance](https://img.shields.io/badge/Maintained%3F-no-red.svg)](https://github.com/CoMPaTech/hAirOS/) +[![CodeFactor](https://www.codefactor.io/repository/github/CoMPaTech/hAirOS/badge)](https://www.codefactor.io/repository/github/CoMPaTech/hAirOS) +[![HASSfest](https://github.com/CoMPaTech/hAirOS/workflows/Validate%20with%20hassfest/badge.svg)](https://github.com/CoMPaTech/hAirOS/actions) +[![Generic badge](https://img.shields.io/github/v/release/CoMPaTech/hAirOS)](https://github.com/CoMPaTech/hAirOS) + +[![CodeRabbit.ai is Awesome](https://img.shields.io/badge/AI-orange?label=CodeRabbit&color=orange&link=https%3A%2F%2Fcoderabbit.ai)](https://coderabbit.ai) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=CoMPaTech_hAirOS&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=CoMPaTech_hAirOS) +[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=CoMPaTech_hAirOS&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=CoMPaTech_hAirOS) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=CoMPaTech_hAirOS&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=CoMPaTech_hAirOS) + +## Requirements + +Only tested/confirmed with airOS 8 on: + +- [x] Nanostation 5AC (LOCO5AC) by @CoMPaTech +- [x] PowerBeam 5AC gen2 by @exico91 + +## Installation + +[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=CoMPaTech&repository=hAirOShAirOhAirOSntegrations) + +## Configuration + +Configure this integration the usual way, requiring your username (`ubnt`), password and IP address of the airOS device. + +## What it provides + +In the current state it retrieves some information and should display the 'other device connected', connection mode, SSID and both actual data being transferred and the maximum capacity. These are displayed as `sensor`s or `binary_sensor`s though most `binary_sensor`s are disabled by default. Additionally for stations connected, child-devices are displayed with both a `binary_sensor` indicating connection and a `button` to force reconnect on the connected device (i.e. the same as the reconnect button on the default homepage of airOS + +## State: BETA + +Even though available does not mean it's stable yet, the HA part is solid but the class used to interact with the API is in need of improvement (e.g. better overall handling). This might also warrant having the class available as a module from pypi. + +## How to install? + +- Use [HACS](https://hacs.xyz) +- Navigate to the `Integrations` page and use the three-dots icon on the top right to add a custom repository. +- Use the link to this page as the URL and select 'Integrations' as the category. +- Look for `airOS` in `Integrations` and install it! + +### How to add the integration to HA Core + +For each device you will have to add it as an integration. + +- [ ] In Home Assistant click on `Configuration` +- [ ] Click on `Integrations` +- [ ] Hit the `+` button in the right lower corner +- [ ] Search or browse for 'Ubiquiti airOS' and click it +- [ ] Enter your details + +### Is it tested? + +It works on my bike and Home Assistant installation :) Let me know if it works on yours! + +[![SonarCloud](https://sonarcloud.io/images/project_badges/sonarcloud-black.svg)](https://sonarcloud.io/summary/new_code?id=CoMPaTech_hAirOS) + +And [Home-Assistant Hassfest](https://github.com/home-assistant/actions) and [HACS validation](https://github.com/hacs/action) diff --git a/custom_components/airos/__init__.py b/custom_components/airos/__init__.py index bad853e..9eea047 100644 --- a/custom_components/airos/__init__.py +++ b/custom_components/airos/__init__.py @@ -2,15 +2,26 @@ from __future__ import annotations -from airos.airos8 import AirOS +from airos.airos8 import AirOS8 -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: @@ -18,13 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo # By default airOS 8 comes with self-signed SSL certificates, # with no option in the web UI to change or upload a custom certificate. - session = async_get_clientsession(hass, verify_ssl=False) + session = async_get_clientsession( + hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] + ) - airos_device = AirOS( + airos_device = AirOS8( host=entry.data[CONF_HOST], username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], session=session, + use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], ) coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) @@ -37,6 +51,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo return True +async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: + """Migrate old config entry.""" + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version == 1: + new_data = {**entry.data} + advanced_data = { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + } + new_data[SECTION_ADVANCED_SETTINGS] = advanced_data + + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=2, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/custom_components/airos/binary_sensor.py b/custom_components/airos/binary_sensor.py index 9d588da..1fc89d5 100644 --- a/custom_components/airos/binary_sensor.py +++ b/custom_components/airos/binary_sensor.py @@ -6,8 +6,6 @@ from dataclasses import dataclass import logging -from airos.data import Station - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -16,42 +14,40 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import StateType -from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator +from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator from .entity import AirOSEntity -from .helpers import get_client_device_info _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): """Describe an AirOS binary sensor.""" - value_fn: Callable[[AirOSData], StateType] + value_fn: Callable[[AirOS8Data], bool] BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( AirOSBinarySensorEntityDescription( key="portfw", translation_key="port_forwarding", - device_class=BinarySensorDeviceClass.DOOR, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.portfw, ), AirOSBinarySensorEntityDescription( key="dhcp_client", translation_key="dhcp_client", - device_class=BinarySensorDeviceClass.CONNECTIVITY, + device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.services.dhcpc, - entity_registry_enabled_default=False, ), AirOSBinarySensorEntityDescription( key="dhcp_server", translation_key="dhcp_server", - device_class=BinarySensorDeviceClass.CONNECTIVITY, + device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.services.dhcpd, entity_registry_enabled_default=False, @@ -59,7 +55,7 @@ class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): AirOSBinarySensorEntityDescription( key="dhcp6_server", translation_key="dhcp6_server", - device_class=BinarySensorDeviceClass.CONNECTIVITY, + device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.services.dhcp6d_stateful, entity_registry_enabled_default=False, @@ -74,12 +70,6 @@ class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): ), ) -CLIENT_BINARY_SENSOR_DESCRIPTION = BinarySensorEntityDescription( - key="connectivity", - device_class=BinarySensorDeviceClass.CONNECTIVITY, - translation_key="client_connectivity" -) - async def async_setup_entry( hass: HomeAssistant, @@ -89,11 +79,9 @@ async def async_setup_entry( """Set up the AirOS binary sensors from a config entry.""" coordinator = config_entry.runtime_data - async_add_entities([AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS], update_before_add=False) - - # Determine remote stations - stations = coordinator.data.wireless.sta - async_add_entities([AirOSClientBinarySensor(coordinator, client_data, stations) for client_data in stations], update_before_add=False) + async_add_entities( + AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) class AirOSBinarySensor(AirOSEntity, BinarySensorEntity): @@ -113,44 +101,6 @@ def __init__( self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return the state of the binary sensor.""" - return bool(self.entity_description.value_fn(self.coordinator.data)) - - -class AirOSClientBinarySensor(AirOSEntity, BinarySensorEntity): - """Represents a connected client (station) to the AirOS device.""" - - entity_description: BinarySensorEntityDescription - - def __init__(self, coordinator: AirOSDataUpdateCoordinator, client_data: Station, clients: list[Station]) -> None: - """Initialize the AirOS client binary sensor.""" - super().__init__(coordinator) - self._client_data = client_data - self._clients = clients - - mac_lower = self._client_data.mac.lower() - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{mac_lower}_connectivity" - - self._attr_device_info = get_client_device_info(coordinator, self._client_data) - - self._update_client_attributes() - - - @property - def is_on(self) -> bool | None: - """Return true if the client is currently connected.""" - for client in self._clients: - if client.mac == self._client_data.mac: - return True # Client found, thus connected - return False # Client not found in the current list, thus disconnected - return None # Return None if not in AP mode or no client data - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_client_attributes() # Update name if hostname changes - super()._handle_coordinator_update() - - def _update_client_attributes(self) -> None: - """Update entity attributes based on current data.""" - self._attr_name = self._client_data.remote.hostname + return self.entity_description.value_fn(self.coordinator.data) diff --git a/custom_components/airos/button.py b/custom_components/airos/button.py.wip similarity index 100% rename from custom_components/airos/button.py rename to custom_components/airos/button.py.wip diff --git a/custom_components/airos/config_flow.py b/custom_components/airos/config_flow.py index 3857313..fac4cce 100644 --- a/custom_components/airos/config_flow.py +++ b/custom_components/airos/config_flow.py @@ -2,24 +2,37 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import section from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) -from .const import DOMAIN -from .coordinator import AirOS +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS +from .coordinator import AirOS8 _LOGGER = logging.getLogger(__name__) @@ -28,6 +41,15 @@ vol.Required(CONF_HOST): str, vol.Required(CONF_USERNAME, default="ubnt"): str, vol.Required(CONF_PASSWORD): str, + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Required(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } + ), + {"collapsed": True}, + ), } ) @@ -36,47 +58,109 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" VERSION = 1 + MINOR_VERSION = 2 + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.airos_device: AirOS8 + self.errors: dict[str, str] = {} async def async_step_user( - self, - user_input: dict[str, Any] | None = None, + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} + """Handle the manual input of host and credentials.""" + self.errors = {} if user_input is not None: - # By default airOS 8 comes with self-signed SSL certificates, - # with no option in the web UI to change or upload a custom certificate. - session = async_get_clientsession(self.hass, verify_ssl=False) - - airos_device = AirOS( - host=user_input[CONF_HOST], - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - session=session, - ) - try: - await airos_device.login() - airos_data = await airos_device.status() - - except ( - ConnectionSetupError, - DeviceConnectionError, - ): - errors["base"] = "cannot_connect" - except (ConnectionAuthenticationError, DataMissingError): - errors["base"] = "invalid_auth" - except KeyDataMissingError: - errors["base"] = "key_data_missing" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + validated_info = await self._validate_and_get_device_info(user_input) + if validated_info: + return self.async_create_entry( + title=validated_info["title"], + data=validated_info["data"], + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors + ) + + async def _validate_and_get_device_info( + self, config_data: dict[str, Any] + ) -> dict[str, Any] | None: + """Validate user input with the device API.""" + # By default airOS 8 comes with self-signed SSL certificates, + # with no option in the web UI to change or upload a custom certificate. + session = async_get_clientsession( + self.hass, + verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], + ) + + airos_device = AirOS8( + host=config_data[CONF_HOST], + username=config_data[CONF_USERNAME], + password=config_data[CONF_PASSWORD], + session=session, + use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], + ) + try: + await airos_device.login() + airos_data = await airos_device.status() + + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + ): + self.errors["base"] = "cannot_connect" + except (AirOSConnectionAuthenticationError, AirOSDataMissingError): + self.errors["base"] = "invalid_auth" + except AirOSKeyDataMissingError: + self.errors["base"] = "key_data_missing" + except Exception: + _LOGGER.exception("Unexpected exception during credential validation") + self.errors["base"] = "unknown" + else: + await self.async_set_unique_id(airos_data.derived.mac) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() else: - await self.async_set_unique_id(airos_data.host.device_id) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=airos_data.host.hostname, data=user_input + + return {"title": airos_data.host.hostname, "data": config_data} + + return None + + async def async_step_reauth( + self, + user_input: Mapping[str, Any], + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm(user_input) + + async def async_step_reauth_confirm( + self, + user_input: Mapping[str, Any], + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + self.errors = {} + + if user_input: + validate_data = {**self._get_reauth_entry().data, **user_input} + if await self._validate_and_get_device_info(config_data=validate_data): + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=validate_data, ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } + ), + errors=self.errors, ) diff --git a/custom_components/airos/const.py b/custom_components/airos/const.py index 99d9f24..29a5f6a 100644 --- a/custom_components/airos/const.py +++ b/custom_components/airos/const.py @@ -1,10 +1,14 @@ -"""AirOS constants for Home Assistant.""" +"""Constants for the Ubiquiti airOS integration.""" from datetime import timedelta DOMAIN = "airos" +SCAN_INTERVAL = timedelta(minutes=1) + MANUFACTURER = "Ubiquiti" -SCAN_INTERVAL = timedelta(minutes=1) +DEFAULT_VERIFY_SSL = False +DEFAULT_SSL = True +SECTION_ADVANCED_SETTINGS = "advanced_settings" diff --git a/custom_components/airos/coordinator.py b/custom_components/airos/coordinator.py index 3f0f1a1..b1f9a77 100644 --- a/custom_components/airos/coordinator.py +++ b/custom_components/airos/coordinator.py @@ -4,17 +4,17 @@ import logging -from airos.airos8 import AirOS, AirOSData +from airos.airos8 import AirOS8, AirOS8Data from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, SCAN_INTERVAL @@ -24,13 +24,13 @@ type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] -class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]): +class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]): """Class to manage fetching AirOS data from single endpoint.""" config_entry: AirOSConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS + self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8 ) -> None: """Initialize the coordinator.""" self.airos_device = airos_device @@ -42,23 +42,27 @@ def __init__( update_interval=SCAN_INTERVAL, ) - async def _async_update_data(self) -> AirOSData: + async def _async_update_data(self) -> AirOS8Data: """Fetch data from AirOS.""" try: await self.airos_device.login() return await self.airos_device.status() - except (ConnectionAuthenticationError,) as err: + except AirOSConnectionAuthenticationError as err: _LOGGER.exception("Error authenticating with airOS device") - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth" ) from err - except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as 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 (DataMissingError,) as err: + except (AirOSDataMissingError,) as err: _LOGGER.error("Expected data not returned by airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, diff --git a/custom_components/airos/diagnostics.py b/custom_components/airos/diagnostics.py index b0bc5dc..70fef68 100644 --- a/custom_components/airos/diagnostics.py +++ b/custom_components/airos/diagnostics.py @@ -14,7 +14,6 @@ HW_REDACT = ["apmac", "hwaddr", "mac"] # MAC address TO_REDACT_HA = [CONF_HOST, CONF_PASSWORD] TO_REDACT_AIROS = [ - "device_id", "hostname", # Prevent leaking device naming "essid", # Network SSID "lat", # GPS latitude to prevent exposing location data. diff --git a/custom_components/airos/entity.py b/custom_components/airos/entity.py index a927405..0b12456 100644 --- a/custom_components/airos/entity.py +++ b/custom_components/airos/entity.py @@ -2,11 +2,11 @@ from __future__ import annotations -from homeassistant.const import CONF_HOST -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.const import CONF_HOST, CONF_SSL +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSDataUpdateCoordinator @@ -20,12 +20,18 @@ def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: super().__init__(coordinator) airos_data = self.coordinator.data + url_schema = ( + "https" + if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] + else "http" + ) configuration_url: str | None = ( - f"https://{coordinator.config_entry.data[CONF_HOST]}" + f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}" ) self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)}, configuration_url=configuration_url, identifiers={(DOMAIN, str(airos_data.host.device_id))}, manufacturer=MANUFACTURER, diff --git a/custom_components/airos/helpers.py b/custom_components/airos/helpers.py.wip similarity index 100% rename from custom_components/airos/helpers.py rename to custom_components/airos/helpers.py.wip diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index d445285..85ec37f 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -1,12 +1,11 @@ { "domain": "airos", - "name": "Ubiquiti UISP AirOS", + "name": "Ubiquiti airOS", "codeowners": ["@CoMPaTech"], "config_flow": true, - "documentation": "https://github.com/CoMPaTech/hairos", - "integration_type": "device", + "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", - "issue_tracker": "https://github.com/CoMPaTech/hairos/issues", - "requirements": ["airos==0.1.6"], - "version": "0.1.6" + "quality_scale": "bronze", + "version": "2.1.0", + "requirements": ["airos==0.5.4"] } diff --git a/custom_components/airos/quality_scale.yaml b/custom_components/airos/quality_scale.yaml new file mode 100644 index 0000000..e8a5ce8 --- /dev/null +++ b/custom_components/airos/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: airOS does not have actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: airOS does not have actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: local_polling without events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: airOS does not have actions + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: no (custom) icons used or envisioned + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/custom_components/airos/sensor.py b/custom_components/airos/sensor.py index 4d15a0c..63c7f8d 100644 --- a/custom_components/airos/sensor.py +++ b/custom_components/airos/sensor.py @@ -6,6 +6,8 @@ from dataclasses import dataclass import logging +from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -17,22 +19,30 @@ SIGNAL_STRENGTH_DECIBELS, UnitOfDataRate, UnitOfFrequency, + UnitOfLength, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator +from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator from .entity import AirOSEntity _LOGGER = logging.getLogger(__name__) +NETROLE_OPTIONS = [mode.value for mode in NetRole] +WIRELESS_MODE_OPTIONS = [mode.value for mode in DerivedWirelessMode] +WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole] + +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AirOSSensorEntityDescription(SensorEntityDescription): """Describe an AirOS sensor.""" - value_fn: Callable[[AirOSData], StateType] + value_fn: Callable[[AirOS8Data], StateType] SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( @@ -41,13 +51,16 @@ class AirOSSensorEntityDescription(SensorEntityDescription): translation_key="host_cpuload", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, value_fn=lambda data: data.host.cpuload, entity_registry_enabled_default=False, ), AirOSSensorEntityDescription( key="host_netrole", translation_key="host_netrole", + device_class=SensorDeviceClass.ENUM, value_fn=lambda data: data.host.netrole.value, + options=NETROLE_OPTIONS, ), AirOSSensorEntityDescription( key="wireless_frequency", @@ -62,11 +75,6 @@ class AirOSSensorEntityDescription(SensorEntityDescription): translation_key="wireless_essid", value_fn=lambda data: data.wireless.essid, ), - AirOSSensorEntityDescription( - key="wireless_mode", - translation_key="wireless_mode", - value_fn=lambda data: data.wireless.mode.value, - ), AirOSSensorEntityDescription( key="wireless_antenna_gain", translation_key="wireless_antenna_gain", @@ -81,6 +89,8 @@ class AirOSSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.tx, ), AirOSSensorEntityDescription( @@ -89,6 +99,8 @@ class AirOSSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.rx, ), AirOSSensorEntityDescription( @@ -97,6 +109,8 @@ class AirOSSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.polling.dl_capacity, ), AirOSSensorEntityDescription( @@ -105,8 +119,45 @@ class AirOSSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.polling.ul_capacity, ), + AirOSSensorEntityDescription( + key="host_uptime", + translation_key="host_uptime", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda data: data.host.uptime, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_distance", + translation_key="wireless_distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfLength.KILOMETERS, + value_fn=lambda data: data.wireless.distance, + ), + AirOSSensorEntityDescription( + key="wireless_mode", + translation_key="wireless_mode", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.mode.value, + options=WIRELESS_MODE_OPTIONS, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_role", + translation_key="wireless_role", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.role.value, + options=WIRELESS_ROLE_OPTIONS, + entity_registry_enabled_default=False, + ), ) @@ -135,7 +186,7 @@ def __init__( super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}" + self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" @property def native_value(self) -> StateType: diff --git a/custom_components/airos/strings.json b/custom_components/airos/strings.json index 79806fa..8630ee8 100644 --- a/custom_components/airos/strings.json +++ b/custom_components/airos/strings.json @@ -2,6 +2,14 @@ "config": { "flow_title": "Ubiquiti airOS device", "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::airos::config::step::user::data_description::password%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -9,9 +17,21 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "IP-address or hostname of the airOS device", + "host": "IP address or hostname of the airOS device", "username": "Administrator username for the airOS device, normally 'ubnt'", "password": "Password configured through the UISP app or web interface" + }, + "sections": { + "advanced_settings": { + "data": { + "ssl": "Use HTTPS", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "ssl": "Whether the connection should be encrypted (required for most devices)", + "verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates" + } + } } } }, @@ -22,13 +42,15 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "Re-authentication should be used for the same device not a new one" } }, "entity": { "binary_sensor": { "port_forwarding": { - "name": "Port forwarding active" + "name": "Port forwarding" }, "dhcp_client": { "name": "DHCP client" @@ -40,7 +62,7 @@ "name": "DHCPv6 server" }, "pppoe": { - "name": "PPPoE" + "name": "PPPoE link" } }, "sensor": { @@ -60,13 +82,6 @@ "wireless_essid": { "name": "Wireless SSID" }, - "wireless_mode": { - "name": "Wireless mode", - "state": { - "ap-ptp": "Access point", - "sta-ptp": "Station" - } - }, "wireless_antenna_gain": { "name": "Antenna gain" }, @@ -84,6 +99,26 @@ }, "wireless_remote_hostname": { "name": "Remote hostname" + }, + "host_uptime": { + "name": "Uptime" + }, + "wireless_distance": { + "name": "Wireless distance" + }, + "wireless_role": { + "name": "Wireless role", + "state": { + "access_point": "Access point", + "station": "Station" + } + }, + "wireless_mode": { + "name": "Wireless mode", + "state": { + "point_to_point": "Point-to-point", + "point_to_multipoint": "Point-to-multipoint" + } } } }, diff --git a/custom_components/airos/translations/en.json b/custom_components/airos/translations/en.json index 9778fe9..a281312 100644 --- a/custom_components/airos/translations/en.json +++ b/custom_components/airos/translations/en.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful", + "unique_id_mismatch": "Re-authentication should be used for the same device not a new one" }, "error": { "cannot_connect": "Failed to connect", @@ -11,6 +13,14 @@ }, "flow_title": "Ubiquiti airOS device", "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "data_description": { + "password": "Password configured through the UISP app or web interface" + } + }, "user": { "data": { "host": "Host", @@ -18,9 +28,21 @@ "username": "Username" }, "data_description": { - "host": "IP-address or hostname of the airOS device", + "host": "IP address or hostname of the airOS device", "password": "Password configured through the UISP app or web interface", "username": "Administrator username for the airOS device, normally 'ubnt'" + }, + "sections": { + "advanced_settings": { + "data": { + "ssl": "Use HTTPS", + "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "ssl": "Whether the connection should be encrypted (required for most devices)", + "verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates" + } + } } } } @@ -37,10 +59,10 @@ "name": "DHCP server" }, "port_forwarding": { - "name": "Port forwarding active" + "name": "Port forwarding" }, "pppoe": { - "name": "PPPoE" + "name": "PPPoE link" } }, "sensor": { @@ -54,9 +76,15 @@ "router": "Router" } }, + "host_uptime": { + "name": "Uptime" + }, "wireless_antenna_gain": { "name": "Antenna gain" }, + "wireless_distance": { + "name": "Wireless distance" + }, "wireless_essid": { "name": "Wireless SSID" }, @@ -66,8 +94,8 @@ "wireless_mode": { "name": "Wireless mode", "state": { - "ap-ptp": "Access point", - "sta-ptp": "Station" + "point_to_multipoint": "Point-to-multipoint", + "point_to_point": "Point-to-point" } }, "wireless_polling_dl_capacity": { @@ -79,6 +107,13 @@ "wireless_remote_hostname": { "name": "Remote hostname" }, + "wireless_role": { + "name": "Wireless role", + "state": { + "access_point": "Access point", + "station": "Station" + } + }, "wireless_throughput_rx": { "name": "Throughput receive (actual)" }, diff --git a/hacs.json b/hacs.json index 558ecf0..713dc7a 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { - "name": "Ubiquity AirOS", - "homeassistant": "2025.7.0", + "name": "Ubiquiti AirOS Core Testing", + "homeassistant": "2025.10.0", "render_readme": true } diff --git a/pyproject.toml b/pyproject.toml index 598482f..f7b5b3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,7 +118,7 @@ lint.ignore = [ "UP006", # keep type annotation style as is "UP007", # keep type annotation style as is # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 - "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + #"UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] diff --git a/scripts/core-testing.sh b/scripts/core-testing.sh index e4a1a6c..7255b72 100755 --- a/scripts/core-testing.sh +++ b/scripts/core-testing.sh @@ -182,7 +182,8 @@ if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "core_prep" ] ; then fi echo -e "${CINFO}Bootstrap pip parts of HA-core${CWARN}" - grep -v "^#" "${coredir}/script/bootstrap" | grep "pip install" | sed 's/python3 -m pip install/uv pip install/g' | sh + # grep -v "^#" "${coredir}/script/bootstrap" | grep "pip install" | sed 's/python3 -m pip install/uv pip install/g' | sh + sh "${coredir}/script/bootstrap" uv pip install -e . --config-settings editable_mode=compat --constraint homeassistant/package_constraints.txt echo "" diff --git a/tests/components/airos/__init__.py b/tests/components/airos/__init__.py index 8c6182a..f663644 100644 --- a/tests/components/airos/__init__.py +++ b/tests/components/airos/__init__.py @@ -1,13 +1,19 @@ """Tests for the Ubiquity airOS integration.""" +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, patch -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform] | None = None, +) -> None: """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.airos._PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py index f4ffceb..8c341a6 100644 --- a/tests/components/airos/conftest.py +++ b/tests/components/airos/conftest.py @@ -1,12 +1,12 @@ """Common fixtures for the Ubiquiti airOS tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from airos.airos8 import AirOS8Data import pytest from homeassistant.components.airos.const import DOMAIN -from homeassistant.components.airos.coordinator import AirOSData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry, load_json_object_fixture @@ -15,8 +15,8 @@ @pytest.fixture def ap_fixture(): """Load fixture data for AP mode.""" - json_data = load_json_object_fixture("ap-ptp.json", DOMAIN) - return AirOSData.from_dict(json_data) + json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN) + return AirOS8Data.from_dict(json_data) @pytest.fixture @@ -28,29 +28,26 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_airos_class() -> Generator[MagicMock]: + """Fixture to mock the AirOS class itself.""" + with ( + patch("homeassistant.components.airos.AirOS8", autospec=True) as mock_class, + patch("homeassistant.components.airos.config_flow.AirOS8", new=mock_class), + patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_class), + ): + yield mock_class + + @pytest.fixture def mock_airos_client( - request: pytest.FixtureRequest, ap_fixture: AirOSData + mock_airos_class: MagicMock, ap_fixture: AirOS8Data ) -> Generator[AsyncMock]: """Fixture to mock the AirOS API client.""" - mock_airos = AsyncMock() - mock_airos.status.return_value = ap_fixture - - if hasattr(request, "param"): - mock_airos.login.side_effect = request.param - else: - mock_airos.login.return_value = True - - with ( - patch( - "homeassistant.components.airos.config_flow.AirOS", return_value=mock_airos - ), - patch( - "homeassistant.components.airos.coordinator.AirOS", return_value=mock_airos - ), - patch("homeassistant.components.airos.AirOS", return_value=mock_airos), - ): - yield mock_airos + client = mock_airos_class.return_value + client.status.return_value = ap_fixture + client.login.return_value = True + return client @pytest.fixture @@ -64,5 +61,5 @@ def mock_config_entry() -> MockConfigEntry: CONF_PASSWORD: "test-password", CONF_USERNAME: "ubnt", }, - unique_id="device0123", + unique_id="01:23:45:67:89:AB", ) diff --git a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json new file mode 100644 index 0000000..06feb3d --- /dev/null +++ b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json @@ -0,0 +1,354 @@ +{ + "chain_names": [ + { + "name": "Chain 0", + "number": 1 + }, + { + "name": "Chain 1", + "number": 2 + } + ], + "derived": { + "access_point": true, + "mac": "01:23:45:67:89:AB", + "mac_interface": "br0", + "mode": "point_to_point", + "ptmp": false, + "ptp": true, + "role": "access_point", + "station": false + }, + "firewall": { + "eb6tables": false, + "ebtables": false, + "ip6tables": false, + "iptables": false + }, + "genuine": "/images/genuine.png", + "gps": { + "alt": null, + "dim": null, + "dop": null, + "fix": 0, + "lat": 52.379894, + "lon": 4.901608, + "sats": null, + "time_synced": null + }, + "host": { + "cpuload": 10.10101, + "device_id": "03aa0d0b40fed0a47088293584ef5432", + "devmodel": "NanoStation 5AC loco", + "freeram": 16564224, + "fwversion": "v8.7.17", + "height": 3, + "hostname": "NanoStation 5AC ap name", + "loadavg": 0.412598, + "netrole": "bridge", + "power_time": 268683, + "temperature": 0, + "time": "2025-06-23 23:06:42", + "timestamp": 2668313184, + "totalram": 63447040, + "uptime": 264888 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "eth0", + "mtu": 1500, + "status": { + "cable_len": 18, + "duplex": true, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": true, + "rx_bytes": 3984971949, + "rx_dropped": 0, + "rx_errors": 4, + "rx_packets": 73564835, + "snr": [30, 30, 30, 30], + "speed": 1000, + "tx_bytes": 209900085624, + "tx_dropped": 10, + "tx_errors": 0, + "tx_packets": 185866883 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "ath0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": false, + "rx_bytes": 206938324766, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 149767200, + "snr": null, + "speed": 0, + "tx_bytes": 5265602738, + "tx_dropped": 2005, + "tx_errors": 0, + "tx_packets": 52980390 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "br0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": [ + { + "addr": "fe80::eea:14ff:fea4:89cd", + "plen": 64 + } + ], + "ipaddr": "192.168.1.2", + "plugged": true, + "rx_bytes": 204802727, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 1791592, + "snr": null, + "speed": 0, + "tx_bytes": 236295176, + "tx_dropped": 0, + "tx_errors": 0, + "tx_packets": 298119 + } + } + ], + "ntpclient": {}, + "portfw": false, + "provmode": {}, + "services": { + "airview": 2, + "dhcp6d_stateful": false, + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 0, + "timestamp": null + }, + "wireless": { + "antenna_gain": 13, + "apmac": "01:23:45:67:89:AB", + "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": "DemoSSID", + "frequency": 5500, + "hide_essid": 0, + "ieeemode": "11ACVHT80", + "mode": "ap-ptp", + "noisef": -89, + "nol_state": 0, + "nol_timeout": 0, + "polling": { + "atpc_status": 2, + "cb_capacity": 593970, + "dl_capacity": 647400, + "ff_cap_rep": false, + "fixed_frame": false, + "flex_mode": null, + "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": { + "link": 266003, + "time": 267181 + }, + "sta": [ + { + "airmax": { + "actual_priority": 0, + "atpc_status": 2, + "beam": 0, + "cb_capacity": 593970, + "desired_priority": 0, + "dl_capacity": 647400, + "rx": { + "cinr": 31, + "evm": [ + [ + 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 + ], + [ + 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": { + "cinr": 31, + "evm": [ + [ + 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 + ], + [ + 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": [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": "192.168.1.2", + "mac": "01:23:45:67:89:AB", + "noisefloor": -89, + "remote": { + "age": 1, + "airview": 2, + "antenna_gain": 13, + "cable_loss": 0, + "chainrssi": [33, 37, 0], + "compat_11n": 0, + "cpuload": 43.564301, + "device_id": "d4f4cdf82961e619328a8f72f8d7653b", + "distance": 1, + "ethlist": [ + { + "cable_len": 14, + "duplex": true, + "enabled": true, + "ifname": "eth0", + "plugged": true, + "snr": [30, 30, 29, 30], + "speed": 1000 + } + ], + "freeram": 14290944, + "gps": { + "alt": null, + "dim": null, + "dop": null, + "fix": 0, + "lat": 52.379894, + "lon": 4.901608, + "sats": null, + "time_synced": null + }, + "height": 2, + "hostname": "NanoStation 5AC sta name", + "ip6addr": ["fe80::eea:14ff:fea4:89ab"], + "ipaddr": ["192.168.1.2"], + "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": { + "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": [ + 14, 4, 372, 2223, 4708, 4037, 8142, 485763, 29420892, 24748154 + ], + "tx_throughput": 16023, + "unms": { + "status": 0, + "timestamp": null + }, + "uptime": 265320, + "version": "WA.ar934x.v8.7.17.48152.250620.2132" + }, + "rssi": 37, + "rx_idx": 8, + "rx_nss": 2, + "signal": -59, + "stats": { + "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": [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": [], + "throughput": { + "rx": 9907, + "tx": 222 + }, + "tx_chainmask": 3, + "tx_idx": 9, + "tx_nss": 2, + "txpower": -3 + } +} diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000..d9815e0 --- /dev/null +++ b/tests/components/airos/snapshots/test_binary_sensor.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_client', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP client', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_client', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_client', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCP client', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_server', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP server', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_server', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_server', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCP server', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_server', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcpv6_server', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCPv6 server', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp6_server', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp6_server', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCPv6 server', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcpv6_server', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_port_forwarding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Port forwarding', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_forwarding', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_portfw', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name Port forwarding', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_port_forwarding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_pppoe_link', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PPPoE link', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pppoe', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_pppoe', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'NanoStation 5AC ap name PPPoE link', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_pppoe_link', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index 2dd7258..4e94bea 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -12,6 +12,16 @@ 'number': 2, }), ]), + 'derived': dict({ + 'access_point': True, + 'mac': '**REDACTED**', + 'mac_interface': 'br0', + 'mode': 'point_to_point', + 'ptmp': False, + 'ptp': True, + 'role': 'access_point', + 'station': False, + }), 'firewall': dict({ 'eb6tables': False, 'ebtables': False, @@ -20,13 +30,18 @@ }), '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': '**REDACTED**', + 'device_id': '03aa0d0b40fed0a47088293584ef5432', 'devmodel': 'NanoStation 5AC loco', 'freeram': 16564224, 'fwversion': 'v8.7.17', @@ -131,6 +146,7 @@ }), 'unms': dict({ 'status': 0, + 'timestamp': None, }), 'wireless': dict({ 'antenna_gain': 13, @@ -159,6 +175,7 @@ 'dl_capacity': 647400, 'ff_cap_rep': False, 'fixed_frame': False, + 'flex_mode': None, 'gps_sync': False, 'rx_use': 42, 'tx_use': 6, @@ -461,6 +478,7 @@ }), 'ul_capacity': 540540, }), + 'airos_connected': True, 'cb_capacity_expect': 416000, 'chainrssi': list([ 35, @@ -489,7 +507,7 @@ ]), 'compat_11n': 0, 'cpuload': 43.564301, - 'device_id': '**REDACTED**', + 'device_id': 'd4f4cdf82961e619328a8f72f8d7653b', 'distance': 1, 'ethlist': list([ dict({ @@ -509,9 +527,14 @@ ]), '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**', @@ -553,6 +576,7 @@ 'tx_throughput': 16023, 'unms': dict({ 'status': 0, + 'timestamp': None, }), 'uptime': 265320, 'version': 'WA.ar934x.v8.7.17.48152.250620.2132', @@ -608,6 +632,10 @@ }), }), 'entry_data': dict({ + 'advanced_settings': dict({ + 'ssl': True, + 'verify_ssl': False, + }), 'host': '**REDACTED**', 'password': '**REDACTED**', 'username': 'ubnt', diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr index e23105d..815b11d 100644 --- a/tests/components/airos/snapshots/test_sensor.ambr +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -26,13 +26,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Antenna Gain', + 'original_name': 'Antenna gain', 'platform': 'airos', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_antenna_gain', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_antenna_gain', + 'unique_id': '01:23:45:67:89:AB_wireless_antenna_gain', 'unit_of_measurement': 'dB', }) # --- @@ -40,7 +40,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'signal_strength', - 'friendly_name': 'NanoStation 5AC ap name Antenna Gain', + 'friendly_name': 'NanoStation 5AC ap name Antenna gain', 'state_class': , 'unit_of_measurement': 'dB', }), @@ -52,6 +52,61 @@ 'state': '13', }) # --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_cpu_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_cpu_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU load', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_cpuload', + 'unique_id': '01:23:45:67:89:AB_host_cpuload', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_cpu_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name CPU load', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_cpu_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.10101', + }) +# --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_download_capacity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -79,6 +134,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -88,24 +146,24 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_polling_dl_capacity', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_polling_dl_capacity', - 'unit_of_measurement': , + 'unique_id': '01:23:45:67:89:AB_wireless_polling_dl_capacity', + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_download_capacity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', - 'friendly_name': 'NanoStation 5AC ap name Download capacity', + 'friendly_name': 'NanoStation 5AC ap name Download capacity', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '647400', + 'state': '647.4', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-entry] @@ -113,7 +171,12 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'bridge', + 'router', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -131,22 +194,27 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Network Role', + 'original_name': 'Network role', 'platform': 'airos', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'host_netrole', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_host_netrole', + 'unique_id': '01:23:45:67:89:AB_host_netrole', 'unit_of_measurement': None, }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'NanoStation 5AC ap name Network Role', + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Network role', + 'options': list([ + 'bridge', + 'router', + ]), }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_network_role', @@ -183,33 +251,36 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Throughput Receive (actual)', + 'original_name': 'Throughput receive (actual)', 'platform': 'airos', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_throughput_rx', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_throughput_rx', - 'unit_of_measurement': , + 'unique_id': '01:23:45:67:89:AB_wireless_throughput_rx', + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_receive_actual-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', - 'friendly_name': 'NanoStation 5AC ap name Throughput Receive (actual)', + 'friendly_name': 'NanoStation 5AC ap name Throughput receive (actual)', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_receive_actual', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '9907', + 'state': '9.907', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-entry] @@ -239,33 +310,36 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Throughput Transmit (actual)', + 'original_name': 'Throughput transmit (actual)', 'platform': 'airos', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_throughput_tx', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_throughput_tx', - 'unit_of_measurement': , + 'unique_id': '01:23:45:67:89:AB_wireless_throughput_tx', + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', - 'friendly_name': 'NanoStation 5AC ap name Throughput Transmit (actual)', + 'friendly_name': 'NanoStation 5AC ap name Throughput transmit (actual)', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_transmit_actual', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '222', + 'state': '0.222', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-entry] @@ -295,6 +369,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -304,24 +381,136 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_polling_ul_capacity', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_polling_ul_capacity', - 'unit_of_measurement': , + 'unique_id': '01:23:45:67:89:AB_wireless_polling_ul_capacity', + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', - 'friendly_name': 'NanoStation 5AC ap name Upload capacity', + 'friendly_name': 'NanoStation 5AC ap name Upload capacity', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '540540', + 'state': '540.54', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_uptime', + 'unique_id': '01:23:45:67:89:AB_host_uptime', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'NanoStation 5AC ap name Uptime', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.06583333333333', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless distance', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_distance', + 'unique_id': '01:23:45:67:89:AB_wireless_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'NanoStation 5AC ap name Wireless distance', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-entry] @@ -351,13 +540,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wireless Frequency', + 'original_name': 'Wireless frequency', 'platform': 'airos', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_frequency', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_frequency', + 'unique_id': '01:23:45:67:89:AB_wireless_frequency', 'unit_of_measurement': , }) # --- @@ -365,7 +554,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', - 'friendly_name': 'NanoStation 5AC ap name Wireless Frequency', + 'friendly_name': 'NanoStation 5AC ap name Wireless frequency', 'state_class': , 'unit_of_measurement': , }), @@ -382,7 +571,12 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'point_to_point', + 'point_to_multipoint', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -400,29 +594,92 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Wireless Mode', + 'original_name': 'Wireless mode', 'platform': 'airos', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_mode', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_mode', + 'unique_id': '01:23:45:67:89:AB_wireless_mode', 'unit_of_measurement': None, }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'NanoStation 5AC ap name Wireless Mode', + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Wireless mode', + 'options': list([ + 'point_to_point', + 'point_to_multipoint', + ]), }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'ap-ptp', + 'state': 'point_to_point', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'station', + 'access_point', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_role', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless role', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_role', + 'unique_id': '01:23:45:67:89:AB_wireless_role', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Wireless role', + 'options': list([ + 'station', + 'access_point', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'access_point', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry] @@ -456,14 +713,14 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wireless_essid', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_wireless_essid', + 'unique_id': '01:23:45:67:89:AB_wireless_essid', 'unit_of_measurement': None, }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'NanoStation 5AC ap name Wireless SSID', + 'friendly_name': 'NanoStation 5AC ap name Wireless SSID', }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_ssid', diff --git a/tests/components/airos/test_binary_sensor.py b/tests/components/airos/test_binary_sensor.py index 77bab36..044ae88 100644 --- a/tests/components/airos/test_binary_sensor.py +++ b/tests/components/airos/test_binary_sensor.py @@ -2,32 +2,27 @@ from unittest.mock import AsyncMock -from homeassistant.components.airos.coordinator import AirOSData -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import pytest + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from syrupy.assertion import SnapshotAssertion from . import setup_integration -from tests.common import MockConfigEntry - -# Mock data for various scenarios -MOCK_CONFIG = { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test_user", - CONF_PASSWORD: "test_password", -} +from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_airos_client: AsyncMock, mock_config_entry: MockConfigEntry, - ap_fixture: AirOSData, entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 34eb025..8f66816 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -4,23 +4,42 @@ from unittest.mock import AsyncMock from airos.exceptions import ( - ConnectionAuthenticationError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import pytest -from homeassistant.components.airos.const import DOMAIN -from homeassistant.components.airos.coordinator import AirOSData +from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + +NEW_PASSWORD = "new_password" +REAUTH_STEP = "reauth_confirm" + MOCK_CONFIG = { CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", + CONF_USERNAME: "ubnt", CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, +} +MOCK_CONFIG_REAUTH = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "wrong-password", } @@ -28,11 +47,12 @@ async def test_form_creates_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_airos_client: AsyncMock, - ap_fixture: AirOSData, + ap_fixture: dict[str, Any], ) -> None: """Test we get the form and create the appropriate entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -44,41 +64,54 @@ async def test_form_creates_entry( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "NanoStation 5AC ap name" - assert result["result"].unique_id == "03aa0d0b40fed0a47088293584ef5432" + assert result["result"].unique_id == "01:23:45:67:89:AB" assert result["data"] == MOCK_CONFIG assert len(mock_setup_entry.mock_calls) == 1 - # Test we can't re-add existing device - result2 = await hass.config_entries.flow.async_init( + +async def test_form_duplicate_entry( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test the form does not allow duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG, ) - assert result2["type"] is FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize( - ("mock_airos_client", "expected_base"), + ("exception", "error"), [ - (ConnectionAuthenticationError, "invalid_auth"), - (DeviceConnectionError, "cannot_connect"), - (KeyDataMissingError, "key_data_missing"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), (Exception, "unknown"), ], - indirect=["mock_airos_client"], ) async def test_form_exception_handling( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_airos_client: AsyncMock, - ap_fixture: dict[str, Any], - expected_base: str, + exception: Exception, + error: str, ) -> None: - """Test we handle invalid auth.""" + """Test we handle exceptions.""" + mock_airos_client.login.side_effect = exception + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -89,10 +122,9 @@ async def test_form_exception_handling( ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": expected_base} + assert result["errors"] == {"base": error} mock_airos_client.login.side_effect = None - mock_airos_client.login.return_value = True result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -103,3 +135,121 @@ async def test_form_exception_handling( assert result["title"] == "NanoStation 5AC ap name" assert result["data"] == MOCK_CONFIG assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_scenario( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reauthentication.""" + mock_config_entry.add_to_hass(hass) + + mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == REAUTH_STEP + + mock_airos_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + user_input={CONF_PASSWORD: NEW_PASSWORD}, + ) + + # Always test resolution + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD + + +@pytest.mark.parametrize( + ("reauth_exception", "expected_error"), + [ + (AirOSConnectionAuthenticationError, "invalid_auth"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), + (Exception, "unknown"), + ], + ids=[ + "invalid_auth", + "cannot_connect", + "key_data_missing", + "unknown", + ], +) +async def test_reauth_flow_scenarios( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + reauth_exception: Exception, + expected_error: str, +) -> None: + """Test reauthentication from start (failure) to finish (success).""" + mock_config_entry.add_to_hass(hass) + + mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == REAUTH_STEP + + mock_airos_client.login.side_effect = reauth_exception + + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + user_input={CONF_PASSWORD: NEW_PASSWORD}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == REAUTH_STEP + assert result["errors"] == {"base": expected_error} + + mock_airos_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + user_input={CONF_PASSWORD: NEW_PASSWORD}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD + + +async def test_reauth_unique_id_mismatch( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication failure when the unique ID changes.""" + mock_config_entry.add_to_hass(hass) + + mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + flows = hass.config_entries.flow.async_progress() + flow = flows[0] + + mock_airos_client.login.side_effect = None + mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB" + + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + user_input={CONF_PASSWORD: NEW_PASSWORD}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] != NEW_PASSWORD diff --git a/tests/components/airos/test_coordinator.py b/tests/components/airos/test_coordinator.py deleted file mode 100644 index f2c6f08..0000000 --- a/tests/components/airos/test_coordinator.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Coordinator Ubiquiti airOS tests.""" - -from asyncio import TimeoutError -from typing import Any -from unittest.mock import AsyncMock - -from airos.exceptions import ( - ConnectionAuthenticationError, - DataMissingError, - DeviceConnectionError, -) -import pytest - -from homeassistant.components.airos.coordinator import ( - AirOSData, - AirOSDataUpdateCoordinator, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import ConfigEntryError, UpdateFailed - - -@pytest.fixture -def mock_hass(): - """Mock HomeAssistant instance.""" - return AsyncMock(spec=HomeAssistant) - - -@pytest.mark.parametrize( - ("mock_airos_client", "expectation_error", "expected_key"), - [ - (ConnectionAuthenticationError, ConfigEntryError, "invalid_auth"), - (TimeoutError, UpdateFailed, "cannot_connect"), - (DeviceConnectionError, UpdateFailed, "cannot_connect"), - (DataMissingError, UpdateFailed, "error_data_missing"), - ], - indirect=["mock_airos_client"], -) -async def test_coordinator_async_update_data_exceptions( - mock_hass: HomeAssistant, - mock_config_entry: ConfigEntry, - mock_airos_client: AsyncMock, - ap_fixture: AirOSData, - expected_key: str, - expectation_error: Any, -) -> None: - """Test async_update_data handles ConnectionAuthenticationError.""" - coordinator = AirOSDataUpdateCoordinator( - mock_hass, mock_config_entry, mock_airos_client - ) - - with pytest.raises(expectation_error) as excinfo: - await coordinator._async_update_data() - assert excinfo.value.translation_key == expected_key - mock_airos_client.login.assert_called_once() - mock_airos_client.status.assert_not_called() diff --git a/tests/components/airos/test_diagnostics.py b/tests/components/airos/test_diagnostics.py index 04aa18a..df2cafd 100644 --- a/tests/components/airos/test_diagnostics.py +++ b/tests/components/airos/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from homeassistant.components.airos.coordinator import AirOSData +from homeassistant.components.airos.coordinator import AirOS8Data from homeassistant.core import HomeAssistant from syrupy.assertion import SnapshotAssertion @@ -18,7 +18,7 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, mock_airos_client: MagicMock, mock_config_entry: MockConfigEntry, - ap_fixture: AirOSData, + ap_fixture: AirOS8Data, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py new file mode 100644 index 0000000..30e2498 --- /dev/null +++ b/tests/components/airos/test_init.py @@ -0,0 +1,169 @@ +"""Test for airOS integration setup.""" + +from __future__ import annotations + +from unittest.mock import ANY, MagicMock + +from homeassistant.components.airos.const import ( + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN, + SECTION_ADVANCED_SETTINGS, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_V1 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", +} + +MOCK_CONFIG_PLAIN = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, +} + +MOCK_CONFIG_V1_2 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + }, +} + + +async def test_setup_entry_with_default_ssl( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airos_client: MagicMock, + mock_airos_class: MagicMock, +) -> None: + """Test setting up a config entry with default SSL options.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_airos_class.assert_called_once_with( + host=mock_config_entry.data[CONF_HOST], + username=mock_config_entry.data[CONF_USERNAME], + password=mock_config_entry.data[CONF_PASSWORD], + session=ANY, + use_ssl=DEFAULT_SSL, + ) + + assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True + assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False + + +async def test_setup_entry_without_ssl( + hass: HomeAssistant, + mock_airos_client: MagicMock, + mock_airos_class: MagicMock, +) -> None: + """Test setting up a config entry adjusted to plain HTTP.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_PLAIN, + entry_id="1", + unique_id="airos_device", + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + mock_airos_class.assert_called_once_with( + host=entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=ANY, + use_ssl=False, + ) + + assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False + assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False + + +async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) -> None: + """Test migrate entry unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1, + entry_id="1", + unique_id="airos_device", + version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data == MOCK_CONFIG_V1_2 + + +async def test_migrate_future_return( + hass: HomeAssistant, + mock_airos_client: MagicMock, +) -> None: + """Test migrate entry unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1_2, + entry_id="1", + unique_id="airos_device", + version=2, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_airos_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup and unload config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py index 620150a..b2ba2db 100644 --- a/tests/components/airos/test_sensor.py +++ b/tests/components/airos/test_sensor.py @@ -3,10 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock +from airos.exceptions import AirOSDataMissingError, AirOSDeviceConnectionError +import pytest + from freezegun.api import FrozenDateTimeFactory from homeassistant.components.airos.const import SCAN_INTERVAL -from homeassistant.components.airos.coordinator import AirOSData -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from syrupy.assertion import SnapshotAssertion @@ -16,37 +18,63 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_airos_client: AsyncMock, mock_config_entry: MockConfigEntry, - ap_fixture: AirOSData, entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, ) -> None: """Test all entities.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - snapshot_platform(hass, entity_registry, snapshot, Platform.SENSOR) - # Add explicit assertion +@pytest.mark.parametrize( + ("exception"), + [ + TimeoutError, + AirOSDeviceConnectionError, + AirOSDataMissingError, + ], +) +async def test_sensor_update_exception_handling( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity update data handles exceptions.""" + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + expected_entity_id = "sensor.nanostation_5ac_ap_name_antenna_gain" signal_state = hass.states.get(expected_entity_id) - assert signal_state is not None, f"Sensor {expected_entity_id} was not created" assert signal_state.state == "13", f"Expected state 13, got {signal_state.state}" assert signal_state.attributes.get("unit_of_measurement") == "dB", ( f"Expected unit 'dB', got {signal_state.attributes.get('unit_of_measurement')}" ) - freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds() + 1)) + mock_airos_client.login.side_effect = exception + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds())) async_fire_time_changed(hass) await hass.async_block_till_done() - # After update, the state should still be the same as the fixture data - signal_state_after_update = hass.states.get(expected_entity_id) - assert signal_state_after_update is not None, ( - f"Sensor {expected_entity_id} changed unexpectedly" + signal_state = hass.states.get(expected_entity_id) + + assert signal_state.state == STATE_UNAVAILABLE, ( + f"Expected state {STATE_UNAVAILABLE}, got {signal_state.state}" ) - mock_airos_client.status.assert_called() + + mock_airos_client.login.side_effect = None + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds())) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + signal_state = hass.states.get(expected_entity_id) + assert signal_state.state == "13", f"Expected state 13, got {signal_state.state}" From 9a5e359251c56b2b0b76568e810785668b6bdac5 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 3 Oct 2025 10:57:43 +0200 Subject: [PATCH 02/18] Migration 1.3 --- custom_components/airos/__init__.py | 59 ++++++++++++++++- custom_components/airos/binary_sensor.py | 3 +- custom_components/airos/config_flow.py | 2 +- .../airos/snapshots/test_binary_sensor.ambr | 10 +-- tests/components/airos/test_init.py | 65 +++++++++++++++++-- 5 files changed, 127 insertions(+), 12 deletions(-) diff --git a/custom_components/airos/__init__.py b/custom_components/airos/__init__.py index 9eea047..5a41e43 100644 --- a/custom_components/airos/__init__.py +++ b/custom_components/airos/__init__.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging + from airos.airos8 import AirOS8 +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -12,7 +15,8 @@ CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +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, SECTION_ADVANCED_SETTINGS @@ -23,6 +27,8 @@ Platform.SENSOR, ] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Set up Ubiquiti airOS from a config entry.""" @@ -72,6 +78,57 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b minor_version=2, ) + # As v6 has no device_id use mac_address in binary_sensor + if entry.version == 1 and entry.minor_version == 2: + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + + if not device_entries: + _LOGGER.debug("Nothing in device registry, assuming migration succeeded") + hass.config_entries.async_update_entry(entry, minor_version=3) + return True # Nothing to migrate, complete version bump + + device_entry = device_entries[0] + mac_address = None + + for connection_type, value in device_entry.connections: + if connection_type == dr.CONNECTION_NETWORK_MAC: + mac_address = dr.format_mac(value) + break + + if not mac_address: # pragma: no cover + _LOGGER.error( + "No MAC address found for device %s, unable to migrate binary_sensors appropriately. Please remove and re-add the integration to avoid duplicate entities", + device_entry.name, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + return True + + @callback + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique id from device_id to mac address.""" + if (euid := entry.unique_id) is not None: + if ( + entity_entry.platform == BINARY_SENSOR_PLATFORM + and entity_entry.unique_id.startswith(euid) + ): + suffix = entity_entry.unique_id.removeprefix(euid) + new_unique_id = f"{mac_address}{suffix}" + _LOGGER.info( + "Migrating entity %s unique_id to %s", + entity_entry.entity_id, + new_unique_id, + ) + return {"new_unique_id": new_unique_id} + return None # pragma: no cover + + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) + + hass.config_entries.async_update_entry(entry, minor_version=3) + + return True diff --git a/custom_components/airos/binary_sensor.py b/custom_components/airos/binary_sensor.py index 1fc89d5..5f5cca5 100644 --- a/custom_components/airos/binary_sensor.py +++ b/custom_components/airos/binary_sensor.py @@ -98,7 +98,8 @@ def __init__( super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}" + self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" + @property def is_on(self) -> bool: diff --git a/custom_components/airos/config_flow.py b/custom_components/airos/config_flow.py index fac4cce..fe219fd 100644 --- a/custom_components/airos/config_flow.py +++ b/custom_components/airos/config_flow.py @@ -58,7 +58,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize the config flow.""" diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr index d9815e0..65705c7 100644 --- a/tests/components/airos/snapshots/test_binary_sensor.ambr +++ b/tests/components/airos/snapshots/test_binary_sensor.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp_client', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_client', + 'unique_id': '01:23:45:67:89:AB_dhcp_client', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp_server', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_server', + 'unique_id': '01:23:45:67:89:AB_dhcp_server', 'unit_of_measurement': None, }) # --- @@ -128,7 +128,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp6_server', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp6_server', + 'unique_id': '01:23:45:67:89:AB_dhcp6_server', 'unit_of_measurement': None, }) # --- @@ -177,7 +177,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_forwarding', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_portfw', + 'unique_id': '01:23:45:67:89:AB_portfw', 'unit_of_measurement': None, }) # --- @@ -225,7 +225,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pppoe', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_pppoe', + 'unique_id': '01:23:45:67:89:AB_pppoe', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py index 30e2498..81948f1 100644 --- a/tests/components/airos/test_init.py +++ b/tests/components/airos/test_init.py @@ -10,6 +10,7 @@ DOMAIN, SECTION_ADVANCED_SETTINGS, ) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -19,6 +20,7 @@ CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -108,8 +110,10 @@ async def test_setup_entry_without_ssl( assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False -async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) -> None: - """Test migrate entry unique id.""" +async def test_ssl_migrate_entry( + hass: HomeAssistant, mock_airos_client: MagicMock +) -> None: + """Test migrate entry SSL options.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -125,10 +129,63 @@ async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) assert entry.state is ConfigEntryState.LOADED assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.minor_version >= 2 assert entry.data == MOCK_CONFIG_V1_2 +async def test_uid_migrate_entry( + hass: HomeAssistant, + mock_airos_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test migrate entry unique id.""" + entity_registry = er.async_get(hass) + + MOCK_MAC = dr.format_mac("01:23:45:67:89:AB") + MOCK_ID = "device_id_12345" + old_unique_id = f"{MOCK_ID}_port_forwarding" + new_unique_id = f"{MOCK_MAC}_port_forwarding" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1_2, + entry_id="1", + unique_id=MOCK_ID, + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_ID)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, MOCK_MAC), + }, + ) + await hass.async_block_till_done() + + old_entity_entry = entity_registry.async_get_or_create( + DOMAIN, BINARY_SENSOR_DOMAIN, old_unique_id, config_entry=entry + ) + original_entity_id = old_entity_entry.entity_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + updated_entity_entry = entity_registry.async_get(original_entity_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 3 + assert ( + entity_registry.async_get_entity_id(BINARY_SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) + assert updated_entity_entry.unique_id == new_unique_id + + async def test_migrate_future_return( hass: HomeAssistant, mock_airos_client: MagicMock, @@ -140,7 +197,7 @@ async def test_migrate_future_return( data=MOCK_CONFIG_V1_2, entry_id="1", unique_id="airos_device", - version=2, + version=3, ) entry.add_to_hass(hass) From fd970274b7f380282003677392fe6039cb3a5f88 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 3 Oct 2025 21:22:46 +0200 Subject: [PATCH 03/18] Add v6 --- custom_components/airos/__init__.py | 26 +- custom_components/airos/binary_sensor.py | 57 +-- custom_components/airos/config_flow.py | 29 +- custom_components/airos/coordinator.py | 19 +- custom_components/airos/entity.py | 3 +- custom_components/airos/sensor.py | 111 +++--- pyproject.toml | 1 + tests/components/airos/conftest.py | 55 ++- .../airos_NanoStation_M5_sta_v6.3.16.json | 158 +++++++++ tests/components/airos/fixtures/ap-ptp.json | 324 ------------------ .../airos/snapshots/test_binary_sensor.ambr | 167 ++++++++- tests/components/airos/test_binary_sensor.py | 5 + tests/components/airos/test_config_flow.py | 124 ++++--- tests/components/airos/test_sensor.py | 1 + 14 files changed, 604 insertions(+), 476 deletions(-) create mode 100644 tests/components/airos/fixtures/airos_NanoStation_M5_sta_v6.3.16.json delete mode 100644 tests/components/airos/fixtures/ap-ptp.json diff --git a/custom_components/airos/__init__.py b/custom_components/airos/__init__.py index 5a41e43..732ca23 100644 --- a/custom_components/airos/__init__.py +++ b/custom_components/airos/__init__.py @@ -4,7 +4,9 @@ import logging +from airos.airos6 import AirOS6 from airos.airos8 import AirOS8 +from airos.helpers import DetectDeviceData, async_get_firmware_data from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.const import ( @@ -39,15 +41,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] ) - airos_device = AirOS8( - host=entry.data[CONF_HOST], - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - session=session, - use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], - ) + conn_data = { + CONF_HOST: entry.data[CONF_HOST], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + "use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], + "session": session, + } + + # Determine firmware version before creating the device instance + device_data: DetectDeviceData = await async_get_firmware_data(**conn_data) + airos_class: type[AirOS8 | AirOS6] = AirOS8 + if device_data["fw_major"] == 6: + airos_class = AirOS6 + + airos_device = airos_class(**conn_data) - coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) + coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/custom_components/airos/binary_sensor.py b/custom_components/airos/binary_sensor.py index 5f5cca5..9e9f04b 100644 --- a/custom_components/airos/binary_sensor.py +++ b/custom_components/airos/binary_sensor.py @@ -4,7 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass -import logging +from typing import Generic, TypeVar + +from airos.data import AirOSDataBaseClass from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -18,25 +20,24 @@ from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator from .entity import AirOSEntity -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 0 +AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass) + @dataclass(frozen=True, kw_only=True) -class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): +class AirOSBinarySensorEntityDescription( + Generic[AirOSDataModel], + BinarySensorEntityDescription, +): """Describe an AirOS binary sensor.""" - value_fn: Callable[[AirOS8Data], bool] + value_fn: Callable[[AirOSDataModel], bool] +AirOS8BinarySensorEntityDescription = AirOSBinarySensorEntityDescription[AirOS8Data] -BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( - AirOSBinarySensorEntityDescription( - key="portfw", - translation_key="port_forwarding", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.portfw, - ), + +COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( AirOSBinarySensorEntityDescription( key="dhcp_client", translation_key="dhcp_client", @@ -52,14 +53,6 @@ class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): value_fn=lambda data: data.services.dhcpd, entity_registry_enabled_default=False, ), - AirOSBinarySensorEntityDescription( - key="dhcp6_server", - translation_key="dhcp6_server", - device_class=BinarySensorDeviceClass.RUNNING, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.services.dhcp6d_stateful, - entity_registry_enabled_default=False, - ), AirOSBinarySensorEntityDescription( key="pppoe", translation_key="pppoe", @@ -70,6 +63,22 @@ class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): ), ) +AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = ( + AirOS8BinarySensorEntityDescription( + key="portfw", + translation_key="port_forwarding", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.portfw, + ), + AirOS8BinarySensorEntityDescription( + key="dhcp6_server", + translation_key="dhcp6_server", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.dhcp6d_stateful, + entity_registry_enabled_default=False, + ), + ) async def async_setup_entry( hass: HomeAssistant, @@ -80,9 +89,15 @@ async def async_setup_entry( coordinator = config_entry.runtime_data async_add_entities( - AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS + AirOSBinarySensor(coordinator, description) + for description in COMMON_BINARY_SENSORS ) + if coordinator.device_data.get("fw_major") == 8: + async_add_entities( + AirOSBinarySensor(coordinator, description) + for description in AIROS8_BINARY_SENSORS + ) class AirOSBinarySensor(AirOSEntity, BinarySensorEntity): """Representation of a binary sensor.""" diff --git a/custom_components/airos/config_flow.py b/custom_components/airos/config_flow.py index fe219fd..b457f2f 100644 --- a/custom_components/airos/config_flow.py +++ b/custom_components/airos/config_flow.py @@ -6,6 +6,8 @@ import logging from typing import Any +from airos.airos6 import AirOS6 +from airos.airos8 import AirOS8 from airos.exceptions import ( AirOSConnectionAuthenticationError, AirOSConnectionSetupError, @@ -13,6 +15,7 @@ AirOSDeviceConnectionError, AirOSKeyDataMissingError, ) +from airos.helpers import DetectDeviceData, async_get_firmware_data import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult @@ -32,9 +35,9 @@ ) from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS -from .coordinator import AirOS8 _LOGGER = logging.getLogger(__name__) +AirOSDeviceDetect = AirOS8 | AirOS6 STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -63,7 +66,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" super().__init__() - self.airos_device: AirOS8 + self.airos_device: AirOSDeviceDetect self.errors: dict[str, str] = {} async def async_step_user( @@ -93,17 +96,14 @@ async def _validate_and_get_device_info( verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], ) - airos_device = AirOS8( - host=config_data[CONF_HOST], - username=config_data[CONF_USERNAME], - password=config_data[CONF_PASSWORD], - session=session, - use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], - ) try: - await airos_device.login() - airos_data = await airos_device.status() - + device_data: DetectDeviceData = await async_get_firmware_data( + host=config_data[CONF_HOST], + username=config_data[CONF_USERNAME], + password=config_data[CONF_PASSWORD], + use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], + session=session, + ) except ( AirOSConnectionSetupError, AirOSDeviceConnectionError, @@ -117,17 +117,18 @@ async def _validate_and_get_device_info( _LOGGER.exception("Unexpected exception during credential validation") self.errors["base"] = "unknown" else: - await self.async_set_unique_id(airos_data.derived.mac) + await self.async_set_unique_id(device_data["mac"]) if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch() else: self._abort_if_unique_id_configured() - return {"title": airos_data.host.hostname, "data": config_data} + return {"title": device_data["hostname"], "data": config_data} return None + async def async_step_reauth( self, user_input: Mapping[str, Any], diff --git a/custom_components/airos/coordinator.py b/custom_components/airos/coordinator.py index b1f9a77..52ca88f 100644 --- a/custom_components/airos/coordinator.py +++ b/custom_components/airos/coordinator.py @@ -4,6 +4,7 @@ import logging +from airos.airos6 import AirOS6, AirOS6Data from airos.airos8 import AirOS8, AirOS8Data from airos.exceptions import ( AirOSConnectionAuthenticationError, @@ -11,6 +12,7 @@ AirOSDataMissingError, AirOSDeviceConnectionError, ) +from airos.helpers import DetectDeviceData from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,19 +23,28 @@ _LOGGER = logging.getLogger(__name__) +AirOSDeviceDetect = AirOS8 | AirOS6 +AirOSDataDetect = AirOS8Data | AirOS6Data + type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] -class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]): +class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]): """Class to manage fetching AirOS data from single endpoint.""" + airos_device: AirOSDeviceDetect config_entry: AirOSConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8 + self, + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + device_data: DetectDeviceData, + airos_device: AirOSDeviceDetect, ) -> None: """Initialize the coordinator.""" self.airos_device = airos_device + self.device_data = device_data super().__init__( hass, _LOGGER, @@ -42,7 +53,7 @@ def __init__( update_interval=SCAN_INTERVAL, ) - async def _async_update_data(self) -> AirOS8Data: + async def _async_update_data(self) -> AirOSDataDetect: """Fetch data from AirOS.""" try: await self.airos_device.login() @@ -62,7 +73,7 @@ async def _async_update_data(self) -> AirOS8Data: translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - except (AirOSDataMissingError,) as err: + except AirOSDataMissingError as err: _LOGGER.error("Expected data not returned by airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, diff --git a/custom_components/airos/entity.py b/custom_components/airos/entity.py index 0b12456..2dff652 100644 --- a/custom_components/airos/entity.py +++ b/custom_components/airos/entity.py @@ -20,6 +20,7 @@ def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: super().__init__(coordinator) airos_data = self.coordinator.data + url_schema = ( "https" if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] @@ -33,7 +34,7 @@ def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)}, configuration_url=configuration_url, - identifiers={(DOMAIN, str(airos_data.host.device_id))}, + identifiers={(DOMAIN, str(airos_data.derived.mac))}, manufacturer=MANUFACTURER, model=airos_data.host.devmodel, name=airos_data.host.hostname, diff --git a/custom_components/airos/sensor.py b/custom_components/airos/sensor.py index 63c7f8d..c6c1f53 100644 --- a/custom_components/airos/sensor.py +++ b/custom_components/airos/sensor.py @@ -5,8 +5,14 @@ from collections.abc import Callable from dataclasses import dataclass import logging +from typing import Generic, TypeVar -from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole +from airos.data import ( + AirOSDataBaseClass, + DerivedWirelessMode, + DerivedWirelessRole, + NetRole, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -37,15 +43,23 @@ PARALLEL_UPDATES = 0 +AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass) + + @dataclass(frozen=True, kw_only=True) -class AirOSSensorEntityDescription(SensorEntityDescription): +class AirOSSensorEntityDescription( + SensorEntityDescription, + Generic[AirOSDataModel], + ): """Describe an AirOS sensor.""" - value_fn: Callable[[AirOS8Data], StateType] + value_fn: Callable[[AirOSDataModel], StateType] + +AirOS8SensorEntityDescription = AirOSSensorEntityDescription[AirOS8Data] -SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( +COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( AirOSSensorEntityDescription( key="host_cpuload", translation_key="host_cpuload", @@ -76,6 +90,44 @@ class AirOSSensorEntityDescription(SensorEntityDescription): value_fn=lambda data: data.wireless.essid, ), AirOSSensorEntityDescription( + key="host_uptime", + translation_key="host_uptime", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda data: data.host.uptime, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_distance", + translation_key="wireless_distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfLength.KILOMETERS, + value_fn=lambda data: data.wireless.distance, + ), + AirOSSensorEntityDescription( + key="wireless_mode", + translation_key="wireless_mode", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.mode.value, + options=WIRELESS_MODE_OPTIONS, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_role", + translation_key="wireless_role", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.role.value, + options=WIRELESS_ROLE_OPTIONS, + entity_registry_enabled_default=False, + ), +) + +AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = ( + AirOS8SensorEntityDescription( key="wireless_antenna_gain", translation_key="wireless_antenna_gain", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -83,7 +135,7 @@ class AirOSSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.wireless.antenna_gain, ), - AirOSSensorEntityDescription( + AirOS8SensorEntityDescription( key="wireless_throughput_tx", translation_key="wireless_throughput_tx", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -93,7 +145,7 @@ class AirOSSensorEntityDescription(SensorEntityDescription): suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.tx, ), - AirOSSensorEntityDescription( + AirOS8SensorEntityDescription( key="wireless_throughput_rx", translation_key="wireless_throughput_rx", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -103,7 +155,7 @@ class AirOSSensorEntityDescription(SensorEntityDescription): suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.rx, ), - AirOSSensorEntityDescription( + AirOS8SensorEntityDescription( key="wireless_polling_dl_capacity", translation_key="wireless_polling_dl_capacity", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -113,7 +165,7 @@ class AirOSSensorEntityDescription(SensorEntityDescription): suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.polling.dl_capacity, ), - AirOSSensorEntityDescription( + AirOS8SensorEntityDescription( key="wireless_polling_ul_capacity", translation_key="wireless_polling_ul_capacity", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -123,41 +175,6 @@ class AirOSSensorEntityDescription(SensorEntityDescription): suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.polling.ul_capacity, ), - AirOSSensorEntityDescription( - key="host_uptime", - translation_key="host_uptime", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - suggested_display_precision=0, - suggested_unit_of_measurement=UnitOfTime.DAYS, - value_fn=lambda data: data.host.uptime, - entity_registry_enabled_default=False, - ), - AirOSSensorEntityDescription( - key="wireless_distance", - translation_key="wireless_distance", - native_unit_of_measurement=UnitOfLength.METERS, - device_class=SensorDeviceClass.DISTANCE, - suggested_display_precision=1, - suggested_unit_of_measurement=UnitOfLength.KILOMETERS, - value_fn=lambda data: data.wireless.distance, - ), - AirOSSensorEntityDescription( - key="wireless_mode", - translation_key="wireless_mode", - device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.derived.mode.value, - options=WIRELESS_MODE_OPTIONS, - entity_registry_enabled_default=False, - ), - AirOSSensorEntityDescription( - key="wireless_role", - translation_key="wireless_role", - device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.derived.role.value, - options=WIRELESS_ROLE_OPTIONS, - entity_registry_enabled_default=False, - ), ) @@ -169,8 +186,14 @@ async def async_setup_entry( """Set up the AirOS sensors from a config entry.""" coordinator = config_entry.runtime_data - async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS) + async_add_entities( + AirOSSensor(coordinator, description) for description in COMMON_SENSORS + ) + if coordinator.device_data.get("fw_major") == 8: + async_add_entities( + AirOSSensor(coordinator, description) for description in AIROS8_SENSORS + ) class AirOSSensor(AirOSEntity, SensorEntity): """Representation of a Sensor.""" diff --git a/pyproject.toml b/pyproject.toml index f7b5b3e..958e5e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ lint.ignore = [ "UP007", # keep type annotation style as is # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 #"UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "UP046", ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py index 8c341a6..f3b3302 100644 --- a/tests/components/airos/conftest.py +++ b/tests/components/airos/conftest.py @@ -3,20 +3,42 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch +from airos.airos6 import AirOS6Data from airos.airos8 import AirOS8Data import pytest +from homeassistant.components.airos.config_flow import DetectDeviceData from homeassistant.components.airos.const import DOMAIN +from homeassistant.components.airos.coordinator import AirOSDataDetect from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry, load_json_object_fixture +AirOSData = AirOS6Data | AirOS8Data + @pytest.fixture -def ap_fixture(): +def ap_fixture(request: pytest.FixtureRequest): """Load fixture data for AP mode.""" - json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN) - return AirOS8Data.from_dict(json_data) + if hasattr(request, "param"): + json_data = load_json_object_fixture(request.param, DOMAIN) + else: + json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN) + + try: + fw_major = int( + json_data.get("host").get("fwversion", "0.0.0").lstrip("v").split(".", 1)[0] + ) + except (ValueError, AttributeError) as err: + raise ValueError("Invalid firmware version in fixture data") from err + + match fw_major: + case 6: + return AirOS6Data.from_dict(json_data) + case 8: + return AirOS8Data.from_dict(json_data) + case _: + raise ValueError(f"Unsupported firmware major version: {fw_major}") @pytest.fixture @@ -33,15 +55,14 @@ def mock_airos_class() -> Generator[MagicMock]: """Fixture to mock the AirOS class itself.""" with ( patch("homeassistant.components.airos.AirOS8", autospec=True) as mock_class, - patch("homeassistant.components.airos.config_flow.AirOS8", new=mock_class), - patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_class), + patch("homeassistant.components.airos.AirOS6", new=mock_class), ): yield mock_class @pytest.fixture def mock_airos_client( - mock_airos_class: MagicMock, ap_fixture: AirOS8Data + mock_airos_class: MagicMock, ap_fixture: AirOSData ) -> Generator[AsyncMock]: """Fixture to mock the AirOS API client.""" client = mock_airos_class.return_value @@ -50,6 +71,28 @@ def mock_airos_client( return client +@pytest.fixture(autouse=True) +def mock_async_get_firmware_data(ap_fixture: AirOSDataDetect): + """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]) + return_value = DetectDeviceData( + fw_major=fw_major, + mac=ap_fixture.derived.mac, + hostname=ap_fixture.host.hostname, + ) + with ( + patch( + "homeassistant.components.airos.config_flow.async_get_firmware_data", + new=AsyncMock(return_value=return_value), + ) as mock_config_flow_firmware, + patch( + "homeassistant.components.airos.async_get_firmware_data", + new=AsyncMock(return_value=return_value), + ) as mock_init_firmware, + ): + yield mock_config_flow_firmware, mock_init_firmware + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the AirOS mocked config entry.""" diff --git a/tests/components/airos/fixtures/airos_NanoStation_M5_sta_v6.3.16.json b/tests/components/airos/fixtures/airos_NanoStation_M5_sta_v6.3.16.json new file mode 100644 index 0000000..509a372 --- /dev/null +++ b/tests/components/airos/fixtures/airos_NanoStation_M5_sta_v6.3.16.json @@ -0,0 +1,158 @@ +{ + "airview": { + "enabled": 0 + }, + "derived": { + "access_point": false, + "mac": "XX:XX:XX:XX:XX:XX", + "mac_interface": "br0", + "mode": "point_to_point", + "ptmp": false, + "ptp": true, + "role": "station", + "station": true + }, + "firewall": { + "eb6tables": false, + "ebtables": true, + "ip6tables": false, + "iptables": false + }, + "genuine": "/images/genuine.png", + "host": { + "cpuload": 24.0, + "devmodel": "NanoStation M5 ", + "freeram": 42516480, + "fwprefix": "XW", + "fwversion": "v6.3.16", + "hostname": "NanoStation M5", + "netrole": "bridge", + "totalram": 63627264, + "uptime": 148479 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "00:00:00:00:00:00", + "ifname": "lo", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "eth0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 100 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "eth1", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": false, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "wifi0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "ath0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "br0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + } + ], + "services": { + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 1, + "timestamp": "" + }, + "wireless": { + "ack": 5, + "antenna": "Built in - 16 dBi", + "apmac": "xxxxxxxx", + "aprepeater": 0, + "cac_nol": 0, + "ccq": 991, + "chains": "2X2", + "chanbw": 40, + "channel": 36, + "countrycode": 840, + "dfs": 0, + "distance": 750, + "essid": "Nano", + "frequency": "5180 MHz", + "hide_essid": 0, + "mode": "sta", + "noisef": -99, + "nol_chans": 0, + "opmode": "11NAHT40PLUS", + "qos": "No QoS", + "rssi": 32, + "rstatus": 5, + "rxrate": "216", + "security": "WPA2", + "signal": -64, + "txpower": 24, + "txrate": "270", + "wds": 1 + } +} diff --git a/tests/components/airos/fixtures/ap-ptp.json b/tests/components/airos/fixtures/ap-ptp.json deleted file mode 100644 index 160b55f..0000000 --- a/tests/components/airos/fixtures/ap-ptp.json +++ /dev/null @@ -1,324 +0,0 @@ -{ - "chain_names": [ - { - "number": 1, - "name": "Chain 0" - }, - { - "number": 2, - "name": "Chain 1" - } - ], - "host": { - "hostname": "NanoStation 5AC ap name", - "device_id": "03aa0d0b40fed0a47088293584ef5432", - "uptime": 264888, - "power_time": 268683, - "time": "2025-06-23 23:06:42", - "timestamp": 2668313184, - "fwversion": "v8.7.17", - "devmodel": "NanoStation 5AC loco", - "netrole": "bridge", - "loadavg": 0.412598, - "totalram": 63447040, - "freeram": 16564224, - "temperature": 0, - "cpuload": 10.10101, - "height": 3 - }, - "genuine": "/images/genuine.png", - "services": { - "dhcpc": false, - "dhcpd": false, - "dhcp6d_stateful": false, - "pppoe": false, - "airview": 2 - }, - "firewall": { - "iptables": false, - "ebtables": false, - "ip6tables": false, - "eb6tables": false - }, - "portfw": false, - "wireless": { - "essid": "DemoSSID", - "mode": "ap-ptp", - "ieeemode": "11ACVHT80", - "band": 2, - "compat_11n": 0, - "hide_essid": 0, - "apmac": "01:23:45:67:89:AB", - "antenna_gain": 13, - "frequency": 5500, - "center1_freq": 5530, - "dfs": 1, - "distance": 0, - "security": "WPA2", - "noisef": -89, - "txpower": -3, - "aprepeater": false, - "rstatus": 5, - "chanbw": 80, - "rx_chainmask": 3, - "tx_chainmask": 3, - "nol_state": 0, - "nol_timeout": 0, - "cac_state": 0, - "cac_timeout": 0, - "rx_idx": 8, - "rx_nss": 2, - "tx_idx": 9, - "tx_nss": 2, - "throughput": { - "tx": 222, - "rx": 9907 - }, - "service": { - "time": 267181, - "link": 266003 - }, - "polling": { - "cb_capacity": 593970, - "dl_capacity": 647400, - "ul_capacity": 540540, - "use": 48, - "tx_use": 6, - "rx_use": 42, - "atpc_status": 2, - "fixed_frame": false, - "gps_sync": false, - "ff_cap_rep": false - }, - "count": 1, - "sta": [ - { - "mac": "01:23:45:67:89:AB", - "lastip": "192.168.1.2", - "signal": -59, - "rssi": 37, - "noisefloor": -89, - "chainrssi": [35, 32, 0], - "tx_idx": 9, - "rx_idx": 8, - "tx_nss": 2, - "rx_nss": 2, - "tx_latency": 0, - "distance": 1, - "tx_packets": 0, - "tx_lretries": 0, - "tx_sretries": 0, - "uptime": 170281, - "dl_signal_expect": -80, - "ul_signal_expect": -55, - "cb_capacity_expect": 416000, - "dl_capacity_expect": 208000, - "ul_capacity_expect": 624000, - "dl_rate_expect": 3, - "ul_rate_expect": 8, - "dl_linkscore": 100, - "ul_linkscore": 86, - "dl_avg_linkscore": 100, - "ul_avg_linkscore": 88, - "tx_ratedata": [175, 4, 47, 200, 673, 158, 163, 138, 68895, 19577430], - "stats": { - "rx_bytes": 206938324814, - "rx_packets": 149767200, - "rx_pps": 846, - "tx_bytes": 5265602739, - "tx_packets": 52980390, - "tx_pps": 0 - }, - "airmax": { - "actual_priority": 0, - "beam": 0, - "desired_priority": 0, - "cb_capacity": 593970, - "dl_capacity": 647400, - "ul_capacity": 540540, - "atpc_status": 2, - "rx": { - "usage": 42, - "cinr": 31, - "evm": [ - [ - 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 - ], - [ - 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 - ] - ] - }, - "tx": { - "usage": 6, - "cinr": 31, - "evm": [ - [ - 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 - ], - [ - 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 - ] - ] - } - }, - "last_disc": 1, - "remote": { - "age": 1, - "device_id": "d4f4cdf82961e619328a8f72f8d7653b", - "hostname": "NanoStation 5AC sta name", - "platform": "NanoStation 5AC loco", - "version": "WA.ar934x.v8.7.17.48152.250620.2132", - "time": "2025-06-23 23:13:54", - "cpuload": 43.564301, - "temperature": 0, - "totalram": 63447040, - "freeram": 14290944, - "netrole": "bridge", - "mode": "sta-ptp", - "sys_id": "0xe7fa", - "tx_throughput": 16023, - "rx_throughput": 251, - "uptime": 265320, - "power_time": 268512, - "compat_11n": 0, - "signal": -58, - "rssi": 38, - "noisefloor": -90, - "tx_power": -4, - "distance": 1, - "rx_chainmask": 3, - "chainrssi": [33, 37, 0], - "tx_ratedata": [ - 14, 4, 372, 2223, 4708, 4037, 8142, 485763, 29420892, 24748154 - ], - "tx_bytes": 212308148210, - "rx_bytes": 3624206478, - "antenna_gain": 13, - "cable_loss": 0, - "height": 2, - "ethlist": [ - { - "ifname": "eth0", - "enabled": true, - "plugged": true, - "duplex": true, - "speed": 1000, - "snr": [30, 30, 29, 30], - "cable_len": 14 - } - ], - "ipaddr": ["192.168.1.2"], - "ip6addr": ["fe80::eea:14ff:fea4:806"], - "gps": { - "lat": "52.379894", - "lon": "4.901608", - "fix": 0 - }, - "oob": false, - "unms": { - "status": 0 - }, - "airview": 2, - "service": { - "time": 267195, - "link": 265996 - } - } - } - ], - "sta_disconnected": [] - }, - "interfaces": [ - { - "ifname": "eth0", - "hwaddr": "01:23:45:67:89:AB", - "enabled": true, - "mtu": 1500, - "status": { - "plugged": true, - "tx_bytes": 209900085624, - "rx_bytes": 3984971949, - "tx_packets": 185866883, - "rx_packets": 73564835, - "tx_errors": 0, - "rx_errors": 4, - "tx_dropped": 10, - "rx_dropped": 0, - "ipaddr": "0.0.0.0", - "speed": 1000, - "duplex": true, - "snr": [30, 30, 30, 30], - "cable_len": 18 - } - }, - { - "ifname": "ath0", - "hwaddr": "01:23:45:67:89:AB", - "enabled": true, - "mtu": 1500, - "status": { - "plugged": false, - "tx_bytes": 5265602738, - "rx_bytes": 206938324766, - "tx_packets": 52980390, - "rx_packets": 149767200, - "tx_errors": 0, - "rx_errors": 0, - "tx_dropped": 2005, - "rx_dropped": 0, - "ipaddr": "0.0.0.0", - "speed": 0, - "duplex": false - } - }, - { - "ifname": "br0", - "hwaddr": "01:23:45:67:89:AB", - "enabled": true, - "mtu": 1500, - "status": { - "plugged": true, - "tx_bytes": 236295176, - "rx_bytes": 204802727, - "tx_packets": 298119, - "rx_packets": 1791592, - "tx_errors": 0, - "rx_errors": 0, - "tx_dropped": 0, - "rx_dropped": 0, - "ipaddr": "192.168.1.2", - "ip6addr": [ - { - "addr": "fe80::eea:14ff:fea4:7b8", - "plen": 64 - } - ], - "speed": 0, - "duplex": false - } - } - ], - "provmode": {}, - "ntpclient": {}, - "unms": { - "status": 0 - }, - "gps": { - "lat": 52.379894, - "lon": 4.901608, - "fix": 0 - } -} diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr index 65705c7..a9d7305 100644 --- a/tests/components/airos/snapshots/test_binary_sensor.ambr +++ b/tests/components/airos/snapshots/test_binary_sensor.ambr @@ -1,5 +1,152 @@ # serializer version: 1 -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-entry] +# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_m5_dhcp_client', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP client', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_client', + 'unique_id': 'XX:XX:XX:XX:XX:XX_dhcp_client', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation M5 DHCP client', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_m5_dhcp_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_server-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_m5_dhcp_server', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP server', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_server', + 'unique_id': 'XX:XX:XX:XX:XX:XX_dhcp_server', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_dhcp_server-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation M5 DHCP server', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_m5_dhcp_server', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_pppoe_link-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_m5_pppoe_link', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PPPoE link', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pppoe', + 'unique_id': 'XX:XX:XX:XX:XX:XX_pppoe', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[airos_NanoStation_M5_sta_v6.3.16.json][binary_sensor.nanostation_m5_pppoe_link-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'NanoStation M5 PPPoE link', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_m5_pppoe_link', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_client-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +181,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-state] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_client-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', @@ -48,7 +195,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-entry] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_server-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -83,7 +230,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-state] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcp_server-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', @@ -97,7 +244,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-entry] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -132,7 +279,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', @@ -146,7 +293,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-entry] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_port_forwarding-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -181,7 +328,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-state] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_port_forwarding-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'NanoStation 5AC ap name Port forwarding', @@ -194,7 +341,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-entry] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_pppoe_link-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -229,7 +376,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-state] +# name: test_all_entities[airos_loco5ac_ap-ptp.json][binary_sensor.nanostation_5ac_ap_name_pppoe_link-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/airos/test_binary_sensor.py b/tests/components/airos/test_binary_sensor.py index 044ae88..76f88e1 100644 --- a/tests/components/airos/test_binary_sensor.py +++ b/tests/components/airos/test_binary_sensor.py @@ -15,6 +15,11 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("ap_fixture"), + ["airos_NanoStation_M5_sta_v6.3.16.json", "airos_loco5ac_ap-ptp.json"], + indirect=True, +) async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 8f66816..6c90149 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Ubiquiti airOS config flow.""" -from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from airos.exceptions import ( AirOSConnectionAuthenticationError, @@ -10,6 +9,7 @@ ) import pytest +from homeassistant.components.airos.config_flow import DetectDeviceData from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( @@ -43,11 +43,33 @@ } + +@pytest.mark.parametrize( + ("ap_fixture", "hostname", "mac", "fw_major"), + [ + ( + "airos_NanoStation_M5_sta_v6.3.16.json", + "NanoStation M5", + "XX:XX:XX:XX:XX:XX", + 6, + ), + ( + "airos_loco5ac_ap-ptp.json", + "NanoStation 5AC ap name", + "01:23:45:67:89:AB", + 8, + ), + ], + indirect=["ap_fixture"], +) async def test_form_creates_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_async_get_firmware_data: AsyncMock, mock_airos_client: AsyncMock, - ap_fixture: dict[str, Any], + hostname: str, + mac: str, + fw_major: int, ) -> None: """Test we get the form and create the appropriate entry.""" result = await hass.config_entries.flow.async_init( @@ -63,8 +85,8 @@ async def test_form_creates_entry( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "NanoStation 5AC ap name" - assert result["result"].unique_id == "01:23:45:67:89:AB" + assert result["title"] == hostname + assert result["result"].unique_id == mac assert result["data"] == MOCK_CONFIG assert len(mock_setup_entry.mock_calls) == 1 @@ -72,6 +94,7 @@ async def test_form_creates_entry( async def test_form_duplicate_entry( hass: HomeAssistant, mock_airos_client: AsyncMock, + mock_async_get_firmware_data: AsyncMock, mock_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock, ) -> None: @@ -110,31 +133,21 @@ async def test_form_exception_handling( error: str, ) -> None: """Test we handle exceptions.""" - mock_airos_client.login.side_effect = exception + with patch( + "homeassistant.components.airos.config_flow.async_get_firmware_data", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - mock_airos_client.login.side_effect = None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "NanoStation 5AC ap name" - assert result["data"] == MOCK_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} async def test_reauth_flow_scenario( @@ -146,7 +159,11 @@ async def test_reauth_flow_scenario( mock_config_entry.add_to_hass(hass) mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError - await hass.config_entries.async_setup(mock_config_entry.entry_id) + with patch( + "homeassistant.components.airos.config_flow.async_get_firmware_data", + side_effect=AirOSConnectionAuthenticationError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -158,9 +175,8 @@ async def test_reauth_flow_scenario( result = await hass.config_entries.flow.async_configure( flow["flow_id"], user_input={CONF_PASSWORD: NEW_PASSWORD}, - ) + ) - # Always test resolution assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -194,7 +210,11 @@ async def test_reauth_flow_scenarios( mock_config_entry.add_to_hass(hass) mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError - await hass.config_entries.async_setup(mock_config_entry.entry_id) + with patch( + "homeassistant.components.airos.config_flow.async_get_firmware_data", + side_effect=AirOSConnectionAuthenticationError + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -202,12 +222,14 @@ async def test_reauth_flow_scenarios( flow = flows[0] assert flow["step_id"] == REAUTH_STEP - mock_airos_client.login.side_effect = reauth_exception - - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], - user_input={CONF_PASSWORD: NEW_PASSWORD}, - ) + with patch( + "homeassistant.components.airos.config_flow.async_get_firmware_data", + side_effect=reauth_exception + ): + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + user_input={CONF_PASSWORD: NEW_PASSWORD}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == REAUTH_STEP @@ -215,9 +237,9 @@ async def test_reauth_flow_scenarios( mock_airos_client.login.side_effect = None result = await hass.config_entries.flow.async_configure( - flow["flow_id"], - user_input={CONF_PASSWORD: NEW_PASSWORD}, - ) + flow["flow_id"], + user_input={CONF_PASSWORD: NEW_PASSWORD}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -235,15 +257,29 @@ async def test_reauth_unique_id_mismatch( mock_config_entry.add_to_hass(hass) mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError - await hass.config_entries.async_setup(mock_config_entry.entry_id) + with patch( + "homeassistant.components.airos.config_flow.async_get_firmware_data", + side_effect=AirOSConnectionAuthenticationError + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 flow = flows[0] mock_airos_client.login.side_effect = None - mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB" + MISMATCH_DEVICE_DATA = DetectDeviceData( + fw_major=8, + mac="FF:23:45:67:89:AB", + hostname="New Device", + ) - result = await hass.config_entries.flow.async_configure( + with patch( + "homeassistant.components.airos.config_flow.async_get_firmware_data", + return_value=MISMATCH_DEVICE_DATA + ): + + result = await hass.config_entries.flow.async_configure( flow["flow_id"], user_input={CONF_PASSWORD: NEW_PASSWORD}, ) diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py index b2ba2db..7dcc34f 100644 --- a/tests/components/airos/test_sensor.py +++ b/tests/components/airos/test_sensor.py @@ -24,6 +24,7 @@ async def test_all_entities( snapshot: SnapshotAssertion, mock_airos_client: AsyncMock, mock_config_entry: MockConfigEntry, + mock_async_get_firmware_data: AsyncMock, entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" From c43a2b85c80679d1dabc3cf0feee618888849ce3 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 3 Oct 2025 21:34:07 +0200 Subject: [PATCH 04/18] Add v6 --- custom_components/airos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 85ec37f..6a35392 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -3,7 +3,7 @@ "name": "Ubiquiti airOS", "codeowners": ["@CoMPaTech"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/airos", + "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", "version": "2.1.0", From 0aea95bfc8a50b3fdd10fece0e6a32a657f56d10 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 3 Oct 2025 21:35:54 +0200 Subject: [PATCH 05/18] Add v6 --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 6a35392..e8df71d 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "version": "2.1.0", - "requirements": ["airos==0.5.4"] + "requirements": ["airos==0.5.4"], + "version": "2.1.0" } From fac1a0692406ba2c0750b76a38d990161edaf7e1 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 5 Oct 2025 11:36:45 +0200 Subject: [PATCH 06/18] Bump version --- CHANGELOG.md | 4 ++++ custom_components/airos/manifest.json | 4 ++-- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 007ccfe..334b1e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Changelog +### OCT 2025 [2.1.x] + +- Migrated to a test instance for Core (not to be used on a regular basis!) + ### JUL 2025 [0.1.0] - Functional device reconnect and align with potential Core PR diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index e8df71d..640bd42 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.5.4"], - "version": "2.1.0" + "requirements": ["airos==0.5.5"], + "version": "2.1.1" } diff --git a/pyproject.toml b/pyproject.toml index 958e5e3..378e4e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "airos-beta" -version = "0.1.6" +version = "2.1.1" description = "Ubiquiti AirOS beta custom-component" readme = "README.md" requires-python = ">=3.13" From 1b85169fddf039156b7098ba3e2d10aeb06564bd Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 13 Oct 2025 14:39:57 +0200 Subject: [PATCH 07/18] Use real form not only formdata --- custom_components/airos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 640bd42..0ec1695 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.5.5"], + "requirements": ["https://test-files.pythonhosted.org/packages/23/49/8bce77a5c44b5a214516eb5a69520b606570e6f4a2eab79380a945529715/airos-0.5.7a0.tar.gz#airos==0.5.7a0"], "version": "2.1.1" } From 3394b1dcf504f429daec3ec8c0e10724f6c46f01 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 13 Oct 2025 15:39:59 +0200 Subject: [PATCH 08/18] Modified login url approach --- custom_components/airos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 0ec1695..f8890ad 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/23/49/8bce77a5c44b5a214516eb5a69520b606570e6f4a2eab79380a945529715/airos-0.5.7a0.tar.gz#airos==0.5.7a0"], + "requirements": ["https://test-files.pythonhosted.org/packages/97/12/09fadda8250318912b382fed4c6110e4d338f03e076a0a3640c2746b4cd3/airos-0.5.7a1.tar.gz#airos==0.5.7a1"], "version": "2.1.1" } From 400dbdfca964ed793244ec48ef3e633e8ea15adc Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 13 Oct 2025 20:03:32 +0200 Subject: [PATCH 09/18] More attempts and more logging --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index f8890ad..a71c08e 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/97/12/09fadda8250318912b382fed4c6110e4d338f03e076a0a3640c2746b4cd3/airos-0.5.7a1.tar.gz#airos==0.5.7a1"], - "version": "2.1.1" + "requirements": ["https://test-files.pythonhosted.org/packages/6f/f5/87ba9d2ce761c3f25f77be848b19c78965476f872bc71c2be11f0f8ec369/airos-0.5.7a2.tar.gz#airos==0.5.7a2"], + "version": "2.1.2a2" } From 8cd294e14516ab42c2eeede2b3122c88031134e4 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 15:38:45 +0200 Subject: [PATCH 10/18] Multiple attempts with form AND encoded data --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index a71c08e..6916af3 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/6f/f5/87ba9d2ce761c3f25f77be848b19c78965476f872bc71c2be11f0f8ec369/airos-0.5.7a2.tar.gz#airos==0.5.7a2"], - "version": "2.1.2a2" + "requirements": ["https://test-files.pythonhosted.org/packages/8d/90/1d23e31bf98f41d569f022903385d072c6051d4055734902070532cdb7b4/airos-0.5.7a3.tar.gz#airos==0.5.7a3"], + "version": "2.1.2a3" } From 74047707c17a1ca0262302b2550dca88c5384b73 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 16:37:54 +0200 Subject: [PATCH 11/18] Multiple attempts with form AND encoded data AND older formdata --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 6916af3..bd23ad4 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/8d/90/1d23e31bf98f41d569f022903385d072c6051d4055734902070532cdb7b4/airos-0.5.7a3.tar.gz#airos==0.5.7a3"], - "version": "2.1.2a3" + "requirements": ["https://test-files.pythonhosted.org/packages/fc/aa/85655abf25ce0fffb9eec31210668526ecd68ba3f09293eb8cdf02079572/airos-0.5.7a4.tar.gz#airos==0.5.7a4"], + "version": "2.1.2a4" } From e3f7d5197e3bae77d606042df165dc433209726d Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 18:54:35 +0200 Subject: [PATCH 12/18] More logging --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index bd23ad4..63ed47d 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/fc/aa/85655abf25ce0fffb9eec31210668526ecd68ba3f09293eb8cdf02079572/airos-0.5.7a4.tar.gz#airos==0.5.7a4"], - "version": "2.1.2a4" + "requirements": ["https://test-files.pythonhosted.org/packages/52/13/0739ffaf10b38f93757821b7c6314aacc91e3bb8d93c27eb92e5eae4f42c/airos-0.5.7a5.tar.gz#airos==0.5.7a5"], + "version": "2.1.2a5" } From 76686a3fa744cbfdab39636daee420ce6c2020f1 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 20:16:48 +0200 Subject: [PATCH 13/18] Rework to redirect302 --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 63ed47d..ba6318b 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/52/13/0739ffaf10b38f93757821b7c6314aacc91e3bb8d93c27eb92e5eae4f42c/airos-0.5.7a5.tar.gz#airos==0.5.7a5"], - "version": "2.1.2a5" + "requirements": ["https://test-files.pythonhosted.org/packages/18/79/45a0ef5d93abde115b141553b5de070d528755eb33e2ec864b495d8448d1/airos-0.5.7a6.tar.gz#airos==0.5.7a6"], + "version": "2.1.2a6" } From 67e6afb2b88ca7ab49d3e491ff033220b0df9610 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 20:35:09 +0200 Subject: [PATCH 14/18] Rework to redirect302 part two --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index ba6318b..3926cbe 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/18/79/45a0ef5d93abde115b141553b5de070d528755eb33e2ec864b495d8448d1/airos-0.5.7a6.tar.gz#airos==0.5.7a6"], - "version": "2.1.2a6" + "requirements": ["https://test-files.pythonhosted.org/packages/e5/c0/2c17a38c0f93a0f489aa18d577a8339e29fc1eb60d537a0a565a3c84d8d6/airos-0.5.7a7.tar.gz#airos==0.5.7a7"], + "version": "2.1.2a7" } From ce2700a7cb4b34371e15fcdd1e57eefcff3eab30 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 20 Oct 2025 16:19:41 +0200 Subject: [PATCH 15/18] Bump to working? version --- custom_components/airos/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 3926cbe..1fda202 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/e5/c0/2c17a38c0f93a0f489aa18d577a8339e29fc1eb60d537a0a565a3c84d8d6/airos-0.5.7a7.tar.gz#airos==0.5.7a7"], - "version": "2.1.2a7" + "requirements": ["https://test-files.pythonhosted.org/packages/8c/5a/ee0e577bcbce53c064b951bbe706bc4fe0b9722a14137e4a3ecbcd2ff1d1/airos-0.5.7a9.tar.gz#airos==0.5.7a9"], + "version": "2.1.2a9" } From 2bc15225d636ba9f96011a2b123d62ce40b9d553 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 20 Oct 2025 21:28:38 +0200 Subject: [PATCH 16/18] Updated sensors --- custom_components/airos/manifest.json | 4 +- custom_components/airos/sensor.py | 26 ++-- ...ros_NanoStation_loco_M5_v6.3.16_XM_ap.json | 145 ++++++++++++++++++ ...os_NanoStation_loco_M5_v6.3.16_XM_sta.json | 145 ++++++++++++++++++ tests/components/airos/test_binary_sensor.py | 2 +- 5 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_ap.json create mode 100644 tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_sta.json diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 1fda202..fb94e57 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/8c/5a/ee0e577bcbce53c064b951bbe706bc4fe0b9722a14137e4a3ecbcd2ff1d1/airos-0.5.7a9.tar.gz#airos==0.5.7a9"], - "version": "2.1.2a9" + "requirements": ["https://test-files.pythonhosted.org/packages/cf/92/6801d673f903d12253eeccb99b928f5e979019598a60201a443149ec7643/airos-0.5.7a11.tar.gz#airos==0.5.7a11"], + "version": "2.1.2a11" } diff --git a/custom_components/airos/sensor.py b/custom_components/airos/sensor.py index c6c1f53..e75f151 100644 --- a/custom_components/airos/sensor.py +++ b/custom_components/airos/sensor.py @@ -124,18 +124,7 @@ class AirOSSensorEntityDescription( options=WIRELESS_ROLE_OPTIONS, entity_registry_enabled_default=False, ), -) - -AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = ( - AirOS8SensorEntityDescription( - key="wireless_antenna_gain", - translation_key="wireless_antenna_gain", - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.wireless.antenna_gain, - ), - AirOS8SensorEntityDescription( + AirOSSensorEntityDescription( key="wireless_throughput_tx", translation_key="wireless_throughput_tx", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -145,7 +134,7 @@ class AirOSSensorEntityDescription( suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.tx, ), - AirOS8SensorEntityDescription( + AirOSSensorEntityDescription( key="wireless_throughput_rx", translation_key="wireless_throughput_rx", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -155,6 +144,17 @@ class AirOSSensorEntityDescription( suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.rx, ), +) + +AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = ( + AirOS8SensorEntityDescription( + key="wireless_antenna_gain", + translation_key="wireless_antenna_gain", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.antenna_gain, + ), AirOS8SensorEntityDescription( key="wireless_polling_dl_capacity", translation_key="wireless_polling_dl_capacity", diff --git a/tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_ap.json b/tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_ap.json new file mode 100644 index 0000000..fe30002 --- /dev/null +++ b/tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_ap.json @@ -0,0 +1,145 @@ +{ + "airview": { + "enabled": 0 + }, + "derived": { + "access_point": true, + "mac": "XX:XX:XX:XX:XX:XX", + "mac_interface": "br0", + "mode": "point_to_point", + "ptmp": false, + "ptp": true, + "role": "access_point", + "sku": "LocoM5", + "station": false + }, + "firewall": { + "eb6tables": false, + "ebtables": true, + "ip6tables": false, + "iptables": false + }, + "genuine": "/images/genuine.png", + "host": { + "cpuload": 100.0, + "devmodel": "NanoStation loco M5 ", + "freeram": 8429568, + "fwprefix": "XM", + "fwversion": "v6.3.16", + "hostname": "NanoStation loco M5 AP", + "netrole": "bridge", + "totalram": 30220288, + "uptime": 1953224 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "00:00:00:00:00:00", + "ifname": "lo", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "eth0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 100 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "wifi0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "ath0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 300 + } + }, + { + "enabled": true, + "hwaddr": "XX:XX:XX:XX:XX:XX", + "ifname": "br0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + } + ], + "services": { + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 0, + "timestamp": "" + }, + "wireless": { + "ack": 28, + "antenna": "Built in - 13 dBi", + "apmac": "XX:XX:XX:XX:XX:XX", + "aprepeater": 0, + "cac_nol": 0, + "ccq": 870, + "chains": "2X2", + "chanbw": 40, + "channel": 140, + "countrycode": 616, + "dfs": 1, + "distance": 600, + "essid": "SOMETHING", + "frequency": "5700 MHz", + "hide_essid": 1, + "mode": "ap", + "noisef": -91, + "nol_chans": 0, + "opmode": "11naht40minus", + "qos": "No QoS", + "rssi": 51, + "rstatus": 5, + "rxrate": "300", + "security": "WPA2", + "signal": -45, + "txpower": 2, + "txrate": "270", + "wds": 1 + } +} \ No newline at end of file diff --git a/tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_sta.json b/tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_sta.json new file mode 100644 index 0000000..30162ae --- /dev/null +++ b/tests/components/airos/fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_sta.json @@ -0,0 +1,145 @@ +{ + "airview": { + "enabled": 0 + }, + "derived": { + "access_point": false, + "mac": "YY:YY:YY:YY:YY:YY", + "mac_interface": "br0", + "mode": "point_to_point", + "ptmp": false, + "ptp": true, + "role": "station", + "sku": "LocoM5", + "station": true + }, + "firewall": { + "eb6tables": false, + "ebtables": true, + "ip6tables": false, + "iptables": false + }, + "genuine": "/images/genuine.png", + "host": { + "cpuload": 100.0, + "devmodel": "NanoStation loco M5 ", + "freeram": 8753152, + "fwprefix": "XM", + "fwversion": "v6.3.16", + "hostname": "NanoStation loco M5 Client", + "netrole": "bridge", + "totalram": 30220288, + "uptime": 1974859 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "00:00:00:00:00:00", + "ifname": "lo", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "YY:YY:YY:YY:YY:YY", + "ifname": "eth0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": null, + "plugged": false, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "YY:YY:YY:YY:YY:YY", + "ifname": "wifi0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + }, + { + "enabled": true, + "hwaddr": "YY:YY:YY:YY:YY:YY", + "ifname": "ath0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 300 + } + }, + { + "enabled": true, + "hwaddr": "YY:YY:YY:YY:YY:YY", + "ifname": "br0", + "mtu": null, + "status": { + "cable_len": null, + "duplex": true, + "ip6addr": null, + "plugged": true, + "snr": null, + "speed": 0 + } + } + ], + "services": { + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 0, + "timestamp": "" + }, + "wireless": { + "ack": 28, + "antenna": "Built in - 13 dBi", + "apmac": "XX:XX:XX:XX:XX:XX", + "aprepeater": 0, + "cac_nol": 0, + "ccq": 738, + "chains": "2X2", + "chanbw": 40, + "channel": 140, + "countrycode": 616, + "dfs": 0, + "distance": 600, + "essid": "SOMETHING", + "frequency": "5700 MHz", + "hide_essid": 0, + "mode": "sta", + "noisef": -89, + "nol_chans": 0, + "opmode": "11naht40minus", + "qos": "No QoS", + "rssi": 50, + "rstatus": 5, + "rxrate": "180", + "security": "WPA2", + "signal": -46, + "txpower": 2, + "txrate": "243", + "wds": 1 + } +} \ No newline at end of file diff --git a/tests/components/airos/test_binary_sensor.py b/tests/components/airos/test_binary_sensor.py index 76f88e1..e387d75 100644 --- a/tests/components/airos/test_binary_sensor.py +++ b/tests/components/airos/test_binary_sensor.py @@ -17,7 +17,7 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("ap_fixture"), - ["airos_NanoStation_M5_sta_v6.3.16.json", "airos_loco5ac_ap-ptp.json"], + ["airos_NanoStation_M5_sta_v6.3.16.json", "airos_loco5ac_ap-ptp.json", "airos_NanoStation_loco_M5_v6.4.16_ap.json"], indirect=True, ) async def test_all_entities( From 59ff7d5f754ecd7b498707e5a988e0c596440eac Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 21 Oct 2025 14:10:26 +0200 Subject: [PATCH 17/18] Fix IeeeMode and add antenna_gain --- custom_components/airos/manifest.json | 4 ++-- custom_components/airos/sensor.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index fb94e57..8cf4b06 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/cf/92/6801d673f903d12253eeccb99b928f5e979019598a60201a443149ec7643/airos-0.5.7a11.tar.gz#airos==0.5.7a11"], - "version": "2.1.2a11" + "requirements": ["https://test-files.pythonhosted.org/packages/a4/ce/51355cc2b02e63f5b705ab284ce92770d014e64d6e7b2fabad1943d50139/airos-0.5.7a12.tar.gz#airos==0.5.7a12"], + "version": "2.1.2a12" } diff --git a/custom_components/airos/sensor.py b/custom_components/airos/sensor.py index e75f151..b32db9c 100644 --- a/custom_components/airos/sensor.py +++ b/custom_components/airos/sensor.py @@ -144,10 +144,7 @@ class AirOSSensorEntityDescription( suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.rx, ), -) - -AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = ( - AirOS8SensorEntityDescription( + AirOSSensorEntityDescription( key="wireless_antenna_gain", translation_key="wireless_antenna_gain", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -155,6 +152,9 @@ class AirOSSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.wireless.antenna_gain, ), +) + +AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = ( AirOS8SensorEntityDescription( key="wireless_polling_dl_capacity", translation_key="wireless_polling_dl_capacity", From 0f4200d23941ad7918a7fb625b849d3022f7d47c Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Wed, 22 Oct 2025 19:49:07 +0200 Subject: [PATCH 18/18] Use latest module and swapping throughput with polling capacity --- custom_components/airos/manifest.json | 4 +-- custom_components/airos/sensor.py | 40 +++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/custom_components/airos/manifest.json b/custom_components/airos/manifest.json index 8cf4b06..44fdfa9 100644 --- a/custom_components/airos/manifest.json +++ b/custom_components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/CoMPaTech/hairos/", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["https://test-files.pythonhosted.org/packages/a4/ce/51355cc2b02e63f5b705ab284ce92770d014e64d6e7b2fabad1943d50139/airos-0.5.7a12.tar.gz#airos==0.5.7a12"], - "version": "2.1.2a12" + "requirements": ["https://test-files.pythonhosted.org/packages/56/8b/a6bda35aae475cd913a2cf738c2fb4d3b8cf11742134008073e1cf73ff1e/airos-0.5.7a14.tar.gz#airos==0.5.7a14"], + "version": "2.1.2a14" } diff --git a/custom_components/airos/sensor.py b/custom_components/airos/sensor.py index b32db9c..11e32d5 100644 --- a/custom_components/airos/sensor.py +++ b/custom_components/airos/sensor.py @@ -125,55 +125,55 @@ class AirOSSensorEntityDescription( entity_registry_enabled_default=False, ), AirOSSensorEntityDescription( - key="wireless_throughput_tx", - translation_key="wireless_throughput_tx", + key="wireless_antenna_gain", + translation_key="wireless_antenna_gain", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.antenna_gain, + ), + AirOSSensorEntityDescription( + key="wireless_polling_dl_capacity", + translation_key="wireless_polling_dl_capacity", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.throughput.tx, + value_fn=lambda data: data.wireless.polling.dl_capacity, ), AirOSSensorEntityDescription( - key="wireless_throughput_rx", - translation_key="wireless_throughput_rx", + key="wireless_polling_ul_capacity", + translation_key="wireless_polling_ul_capacity", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.throughput.rx, - ), - AirOSSensorEntityDescription( - key="wireless_antenna_gain", - translation_key="wireless_antenna_gain", - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.wireless.antenna_gain, + value_fn=lambda data: data.wireless.polling.ul_capacity, ), ) AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = ( AirOS8SensorEntityDescription( - key="wireless_polling_dl_capacity", - translation_key="wireless_polling_dl_capacity", + key="wireless_throughput_tx", + translation_key="wireless_throughput_tx", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.polling.dl_capacity, + value_fn=lambda data: data.wireless.throughput.tx, ), AirOS8SensorEntityDescription( - key="wireless_polling_ul_capacity", - translation_key="wireless_polling_ul_capacity", + key="wireless_throughput_rx", + translation_key="wireless_throughput_rx", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.polling.ul_capacity, + value_fn=lambda data: data.wireless.throughput.rx, ), )