Introduce Ubiquiti UISP airOS (#148989)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Tom 2025-07-29 19:41:13 +02:00 committed by GitHub
parent 25407c0f4b
commit b67e85e8da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1708 additions and 1 deletions

View File

@ -53,6 +53,7 @@ homeassistant.components.air_quality.*
homeassistant.components.airgradient.*
homeassistant.components.airly.*
homeassistant.components.airnow.*
homeassistant.components.airos.*
homeassistant.components.airq.*
homeassistant.components.airthings.*
homeassistant.components.airthings_ble.*

2
CODEOWNERS generated
View File

@ -67,6 +67,8 @@ build.json @home-assistant/supervisor
/tests/components/airly/ @bieniu
/homeassistant/components/airnow/ @asymworks
/tests/components/airnow/ @asymworks
/homeassistant/components/airos/ @CoMPaTech
/tests/components/airos/ @CoMPaTech
/homeassistant/components/airq/ @Sibgatulin @dl2080
/tests/components/airq/ @Sibgatulin @dl2080
/homeassistant/components/airthings/ @danielhiversen @LaStrada

View File

@ -1,5 +1,5 @@
{
"domain": "ubiquiti",
"name": "Ubiquiti",
"integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"]
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
}

View File

@ -0,0 +1,42 @@
"""The Ubiquiti airOS integration."""
from __future__ import annotations
from airos.airos8 import AirOS
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Set up Ubiquiti airOS from a config entry."""
# 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)
airos_device = AirOS(
host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
)
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
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)

View File

@ -0,0 +1,82 @@
"""Config flow for the Ubiquiti airOS integration."""
from __future__ import annotations
import logging
from typing import Any
from airos.exceptions import (
ConnectionAuthenticationError,
ConnectionSetupError,
DataMissingError,
DeviceConnectionError,
KeyDataMissingError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import AirOS
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
VERSION = 1
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
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"
else:
await self.async_set_unique_id(airos_data.derived.mac)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=airos_data.host.hostname, data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@ -0,0 +1,9 @@
"""Constants for the Ubiquiti airOS integration."""
from datetime import timedelta
DOMAIN = "airos"
SCAN_INTERVAL = timedelta(minutes=1)
MANUFACTURER = "Ubiquiti"

View File

@ -0,0 +1,66 @@
"""DataUpdateCoordinator for AirOS."""
from __future__ import annotations
import logging
from airos.airos8 import AirOS, AirOSData
from airos.exceptions import (
ConnectionAuthenticationError,
ConnectionSetupError,
DataMissingError,
DeviceConnectionError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
"""Class to manage fetching AirOS data from single endpoint."""
config_entry: AirOSConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS
) -> None:
"""Initialize the coordinator."""
self.airos_device = airos_device
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> AirOSData:
"""Fetch data from AirOS."""
try:
await self.airos_device.login()
return await self.airos_device.status()
except (ConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (ConnectionSetupError, DeviceConnectionError, 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:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_data_missing",
) from err

View File

@ -0,0 +1,36 @@
"""Generic AirOS Entity Class."""
from __future__ import annotations
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirOSDataUpdateCoordinator
class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
"""Represent a AirOS Entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None:
"""Initialise the gateway."""
super().__init__(coordinator)
airos_data = self.coordinator.data
configuration_url: str | None = (
f"https://{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,
model=airos_data.host.devmodel,
name=airos_data.host.hostname,
sw_version=airos_data.host.fwversion,
)

View File

@ -0,0 +1,10 @@
{
"domain": "airos",
"name": "Ubiquiti airOS",
"codeowners": ["@CoMPaTech"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["airos==0.2.1"]
}

View File

@ -0,0 +1,72 @@
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: todo
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:
status: todo
comment: prepared binary_sensors will provide this
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

View File

@ -0,0 +1,152 @@
"""AirOS Sensor component for Home Assistant."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from airos.data import NetRole, WirelessMode
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
UnitOfDataRate,
UnitOfFrequency,
)
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 .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
WIRELESS_MODE_OPTIONS = [mode.value.replace("-", "_").lower() for mode in WirelessMode]
NETROLE_OPTIONS = [mode.value for mode in NetRole]
@dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription(SensorEntityDescription):
"""Describe an AirOS sensor."""
value_fn: Callable[[AirOSData], StateType]
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
AirOSSensorEntityDescription(
key="host_cpuload",
translation_key="host_cpuload",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
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",
translation_key="wireless_frequency",
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.frequency,
),
AirOSSensorEntityDescription(
key="wireless_essid",
translation_key="wireless_essid",
value_fn=lambda data: data.wireless.essid,
),
AirOSSensorEntityDescription(
key="wireless_mode",
translation_key="wireless_mode",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(),
options=WIRELESS_MODE_OPTIONS,
),
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,
),
AirOSSensorEntityDescription(
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,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOSSensorEntityDescription(
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,
value_fn=lambda data: data.wireless.throughput.rx,
),
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,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
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,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
class AirOSSensor(AirOSEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: AirOSSensorEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: AirOSSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,87 @@
{
"config": {
"flow_title": "Ubiquiti airOS device",
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"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"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"key_data_missing": "Expected data not returned from the device, check the documentation for supported devices",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"sensor": {
"host_cpuload": {
"name": "CPU load"
},
"host_netrole": {
"name": "Network role",
"state": {
"bridge": "Bridge",
"router": "Router"
}
},
"wireless_frequency": {
"name": "Wireless frequency"
},
"wireless_essid": {
"name": "Wireless SSID"
},
"wireless_mode": {
"name": "Wireless mode",
"state": {
"ap_ptp": "Access point",
"sta_ptp": "Station"
}
},
"wireless_antenna_gain": {
"name": "Antenna gain"
},
"wireless_throughput_tx": {
"name": "Throughput transmit (actual)"
},
"wireless_throughput_rx": {
"name": "Throughput receive (actual)"
},
"wireless_polling_dl_capacity": {
"name": "Download capacity"
},
"wireless_polling_ul_capacity": {
"name": "Upload capacity"
},
"wireless_remote_hostname": {
"name": "Remote hostname"
}
}
},
"exceptions": {
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"key_data_missing": {
"message": "Key data not returned from device"
},
"error_data_missing": {
"message": "Data incomplete or missing"
}
}
}

View File

@ -37,6 +37,7 @@ FLOWS = {
"airgradient",
"airly",
"airnow",
"airos",
"airq",
"airthings",
"airthings_ble",

View File

@ -7002,6 +7002,12 @@
"ubiquiti": {
"name": "Ubiquiti",
"integrations": {
"airos": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"name": "Ubiquiti airOS"
},
"unifi": {
"integration_type": "hub",
"config_flow": true,

10
mypy.ini generated
View File

@ -285,6 +285,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.airos.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.airq.*]
check_untyped_defs = true
disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@ -452,6 +452,9 @@ airgradient==0.9.2
# homeassistant.components.airly
airly==1.1.0
# homeassistant.components.airos
airos==0.2.1
# homeassistant.components.airthings_ble
airthings-ble==0.9.2

View File

@ -434,6 +434,9 @@ airgradient==0.9.2
# homeassistant.components.airly
airly==1.1.0
# homeassistant.components.airos
airos==0.2.1
# homeassistant.components.airthings_ble
airthings-ble==0.9.2

View File

@ -0,0 +1,13 @@
"""Tests for the Ubiquity airOS integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,61 @@
"""Common fixtures for the Ubiquiti airOS tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from airos.airos8 import AirOSData
import pytest
from homeassistant.components.airos.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
def ap_fixture():
"""Load fixture data for AP mode."""
json_data = load_json_object_fixture("airos_ap-ptp.json", DOMAIN)
return AirOSData.from_dict(json_data)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.airos.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_airos_client(
request: pytest.FixtureRequest, ap_fixture: AirOSData
) -> Generator[AsyncMock]:
"""Fixture to mock the AirOS API client."""
with (
patch(
"homeassistant.components.airos.config_flow.AirOS", autospec=True
) as mock_airos,
patch("homeassistant.components.airos.coordinator.AirOS", new=mock_airos),
patch("homeassistant.components.airos.AirOS", new=mock_airos),
):
client = mock_airos.return_value
client.status.return_value = ap_fixture
client.login.return_value = True
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the AirOS mocked config entry."""
return MockConfigEntry(
title="NanoStation",
domain=DOMAIN,
data={
CONF_HOST: "1.1.1.1",
CONF_PASSWORD: "test-password",
CONF_USERNAME: "ubnt",
},
unique_id="01:23:45:67:89:AB",
)

View File

@ -0,0 +1,300 @@
{
"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:89ab"],
"gps": { "lat": "52.379894", "lon": "4.901608", "fix": 0 },
"oob": false,
"unms": { "status": 0, "timestamp": null },
"airview": 2,
"service": { "time": 267195, "link": 265996 }
},
"airos_connected": true
}
],
"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,
"ip6addr": null
}
},
{
"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,
"snr": null,
"cable_len": null,
"ip6addr": null
}
},
{
"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",
"speed": 0,
"duplex": false,
"snr": null,
"cable_len": null,
"ip6addr": [{ "addr": "fe80::eea:14ff:fea4:89cd", "plen": 64 }]
}
}
],
"provmode": {},
"ntpclient": {},
"unms": { "status": 0, "timestamp": null },
"gps": { "lat": 52.379894, "lon": 4.901608, "fix": 0 },
"derived": { "mac": "01:23:45:67:89:AB", "mac_interface": "br0" }
}

View File

@ -0,0 +1,547 @@
# serializer version: 1
# name: test_all_entities[sensor.nanostation_5ac_ap_name_antenna_gain-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_antenna_gain',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Antenna gain',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wireless_antenna_gain',
'unique_id': '01:23:45:67:89:AB_wireless_antenna_gain',
'unit_of_measurement': 'dB',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_antenna_gain-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': 'NanoStation 5AC ap name Antenna gain',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dB',
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_antenna_gain',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '13',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_cpu_load-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_cpu_load',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10.10101',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_download_capacity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Download capacity',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wireless_polling_dl_capacity',
'unique_id': '01:23:45:67:89:AB_wireless_polling_dl_capacity',
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
})
# ---
# 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',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '647400',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'bridge',
'router',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_network_role',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Network role',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': '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({
'device_class': 'enum',
'friendly_name': 'NanoStation 5AC ap name Network role',
'options': list([
'bridge',
'router',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_network_role',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'bridge',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_receive_actual-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_receive_actual',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'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': '01:23:45:67:89:AB_wireless_throughput_rx',
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
})
# ---
# 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)',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_receive_actual',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9907',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_transmit_actual',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'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': '01:23:45:67:89:AB_wireless_throughput_tx',
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
})
# ---
# 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)',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_transmit_actual',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '222',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Upload capacity',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wireless_polling_ul_capacity',
'unique_id': '01:23:45:67:89:AB_wireless_polling_ul_capacity',
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
})
# ---
# 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',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '540540',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_frequency',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.FREQUENCY: 'frequency'>,
'original_icon': None,
'original_name': 'Wireless frequency',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wireless_frequency',
'unique_id': '01:23:45:67:89:AB_wireless_frequency',
'unit_of_measurement': <UnitOfFrequency.MEGAHERTZ: 'MHz'>,
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'frequency',
'friendly_name': 'NanoStation 5AC ap name Wireless frequency',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfFrequency.MEGAHERTZ: 'MHz'>,
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_frequency',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5500',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'ap_ptp',
'sta_ptp',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Wireless mode',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': '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({
'device_class': 'enum',
'friendly_name': 'NanoStation 5AC ap name Wireless mode',
'options': list([
'ap_ptp',
'sta_ptp',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'ap_ptp',
})
# ---
# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_ssid',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Wireless SSID',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': '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',
}),
'context': <ANY>,
'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_ssid',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'DemoSSID',
})
# ---

View File

@ -0,0 +1,119 @@
"""Test the Ubiquiti airOS config flow."""
from typing import Any
from unittest.mock import AsyncMock
from airos.exceptions import (
ConnectionAuthenticationError,
DeviceConnectionError,
KeyDataMissingError,
)
import pytest
from homeassistant.components.airos.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
MOCK_CONFIG = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
}
async def test_form_creates_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_airos_client: AsyncMock,
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}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
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["result"].unique_id == "01:23:45:67:89:AB"
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
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"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("exception", "error"),
[
(ConnectionAuthenticationError, "invalid_auth"),
(DeviceConnectionError, "cannot_connect"),
(KeyDataMissingError, "key_data_missing"),
(Exception, "unknown"),
],
)
async def test_form_exception_handling(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_airos_client: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test we handle exceptions."""
mock_airos_client.login.side_effect = exception
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,
)
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

View File

@ -0,0 +1,85 @@
"""Test the Ubiquiti airOS sensors."""
from datetime import timedelta
from unittest.mock import AsyncMock
from airos.exceptions import (
ConnectionAuthenticationError,
DataMissingError,
DeviceConnectionError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.airos.const import SCAN_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
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,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("exception"),
[
ConnectionAuthenticationError,
TimeoutError,
DeviceConnectionError,
DataMissingError,
],
)
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)
expected_entity_id = "sensor.nanostation_5ac_ap_name_antenna_gain"
signal_state = hass.states.get(expected_entity_id)
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')}"
)
mock_airos_client.login.side_effect = exception
freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds() + 1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
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.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}"