Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project will be documented in this file.

## [0.5.0] - 2025-08-30

Initial support for firmware 6

### Added

- Add logging redacted data on interface [issue](https://github.com/home-assistant/core/issues/151348)
- W.r.t. reported NanoBeam 8.7.18; Mark mtu optional on interfaces
- W.r.t. reported NanoStation 6.3.16-22; Provide preliminary status reporting

## [0.4.4] - 2025-08-29

### Changed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ if __name__ == "__main__":

## Supported API classes and calls

Note: For firmware 6 we only support the login and status calls currently.

### Classes

- `airos.data` (directly) as well as `airos.airos8` (indirectly) provides `AirOSData`, a [mashumaro](https://pypi.org/project/mashumaro/) based dataclass
Expand Down
64 changes: 64 additions & 0 deletions airos/airos6.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Ubiquiti AirOS 6."""

from __future__ import annotations

import logging
from typing import Any

from .airos8 import AirOS
from .data import AirOS6Data, DerivedWirelessRole
from .exceptions import AirOSNotSupportedError

_LOGGER = logging.getLogger(__name__)


class AirOS6(AirOS):
"""AirOS 6 connection class."""

data_model = AirOS6Data

@staticmethod
def derived_wireless_data(
derived: dict[str, Any], response: dict[str, Any]
) -> dict[str, Any]:
"""Add derived wireless data to the device response."""
# Access Point / Station - no info on ptp/ptmp
# assuming ptp for station mode
derived["ptp"] = True
wireless_mode = response.get("wireless", {}).get("mode", "")
match wireless_mode:
case "ap":
derived["access_point"] = True
derived["role"] = DerivedWirelessRole.ACCESS_POINT
case "sta":
derived["station"] = True

return derived

async def update_check(self, force: bool = False) -> dict[str, Any]:
"""Check for firmware updates. Not supported on AirOS6."""
raise AirOSNotSupportedError("Firmware update check not supported on AirOS6.")

async def stakick(self, mac_address: str | None = None) -> bool:
"""Kick a station off the AP. Not supported on AirOS6."""
raise AirOSNotSupportedError("Station kick not supported on AirOS6.")

async def provmode(self, active: bool = False) -> bool:
"""Enable/Disable provisioning mode. Not supported on AirOS6."""
raise AirOSNotSupportedError("Provisioning mode not supported on AirOS6.")

async def warnings(self) -> dict[str, Any]:
"""Get device warnings. Not supported on AirOS6."""
raise AirOSNotSupportedError("Device warnings not supported on AirOS6.")

async def progress(self) -> dict[str, Any]:
"""Get firmware progress. Not supported on AirOS6."""
raise AirOSNotSupportedError("Firmware progress not supported on AirOS6.")

async def download(self) -> dict[str, Any]:
"""Download the device firmware. Not supported on AirOS6."""
raise AirOSNotSupportedError("Firmware download not supported on AirOS6.")

async def install(self) -> dict[str, Any]:
"""Install a firmware update. Not supported on AirOS6."""
raise AirOSNotSupportedError("Firmware install not supported on AirOS6.")
55 changes: 40 additions & 15 deletions airos/airos8.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
from __future__ import annotations

import asyncio
from collections.abc import Callable
from http.cookies import SimpleCookie
import json
import logging
from typing import Any
from typing import Any, TypeVar
from urllib.parse import urlparse

import aiohttp
from mashumaro.exceptions import InvalidFieldValue, MissingField

from .data import (
AirOS8Data as AirOSData,
AirOS8Data,
AirOSDataBaseClass,
DerivedWirelessMode,
DerivedWirelessRole,
redact_data_smart,
Expand All @@ -28,10 +30,14 @@

_LOGGER = logging.getLogger(__name__)

AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)


class AirOS:
"""AirOS 8 connection class."""

data_model: type[AirOSDataBaseClass] = AirOS8Data

def __init__(
self,
host: str,
Expand Down Expand Up @@ -74,17 +80,10 @@ def __init__(
self.connected: bool = False

@staticmethod
def derived_data(response: dict[str, Any]) -> dict[str, Any]:
"""Add derived data to the device response."""
derived: dict[str, Any] = {
"station": False,
"access_point": False,
"ptp": False,
"ptmp": False,
"role": DerivedWirelessRole.STATION,
"mode": DerivedWirelessMode.PTP,
}

def derived_wireless_data(
derived: dict[str, Any], response: dict[str, Any]
) -> dict[str, Any]:
"""Add derived wireless data to the device response."""
# Access Point / Station vs PTP/PtMP
wireless_mode = response.get("wireless", {}).get("mode", "")
match wireless_mode:
Expand All @@ -104,6 +103,27 @@ def derived_data(response: dict[str, Any]) -> dict[str, Any]:
case "sta-ptp":
derived["station"] = True
derived["ptp"] = True
return derived

@staticmethod
def _derived_data_helper(
response: dict[str, Any],
derived_wireless_data_func: Callable[
[dict[str, Any], dict[str, Any]], dict[str, Any]
],
) -> dict[str, Any]:
"""Add derived data to the device response."""
derived: dict[str, Any] = {
"station": False,
"access_point": False,
"ptp": False,
"ptmp": False,
"role": DerivedWirelessRole.STATION,
"mode": DerivedWirelessMode.PTP,
}

# WIRELESS
derived = derived_wireless_data_func(derived, response)

# INTERFACES
addresses = {}
Expand All @@ -113,6 +133,7 @@ def derived_data(response: dict[str, Any]) -> dict[str, Any]:

# No interfaces, no mac, no usability
if not interfaces:
_LOGGER.error("Failed to determine interfaces from AirOS data")
raise AirOSKeyDataMissingError from None

for interface in interfaces:
Expand All @@ -133,6 +154,10 @@ def derived_data(response: dict[str, Any]) -> dict[str, Any]:

return response

def derived_data(self, response: dict[str, Any]) -> dict[str, Any]:
"""Add derived data to the device response (instance method for polymorphism)."""
return self._derived_data_helper(response, self.derived_wireless_data)

def _get_authenticated_headers(
self,
ct_json: bool = False,
Expand Down Expand Up @@ -234,15 +259,15 @@ async def login(self) -> None:
except (AirOSConnectionAuthenticationError, AirOSConnectionSetupError) as err:
raise AirOSConnectionSetupError("Failed to login to AirOS device") from err

async def status(self) -> AirOSData:
async def status(self) -> AirOSDataBaseClass:
"""Retrieve status from the device."""
response = await self._request_json(
"GET", self._status_cgi_url, authenticated=True
)

try:
adjusted_json = self.derived_data(response)
return AirOSData.from_dict(adjusted_json)
return self.data_model.from_dict(adjusted_json)
except InvalidFieldValue as err:
# Log with .error() as this is a specific, known type of issue
redacted_data = redact_data_smart(response)
Expand Down
Loading