Skip to content

Commit 994c78e

Browse files
committed
Introduce basic v6 support
1 parent 4909013 commit 994c78e

17 files changed

+1872
-142
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

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

5+
## [0.5.0] - 2025-08-30
6+
7+
Initial support for firmware 6
8+
9+
### Added
10+
11+
- Add logging redacted data on interface [issue](https://github.com/home-assistant/core/issues/151348)
12+
- W.r.t. reported NanoBeam 8.7.18; Mark mtu optional on interfaces
13+
- W.r.t. reported NanoStation 6.3.16-22; Provide preliminary status reporting
14+
515
## [0.4.4] - 2025-08-29
616

717
### Changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ if __name__ == "__main__":
113113

114114
## Supported API classes and calls
115115

116+
Note: For firmware 6 we only support the login and status calls currently.
117+
116118
### Classes
117119

118120
- `airos.data` (directly) as well as `airos.airos8` (indirectly) provides `AirOSData`, a [mashumaro](https://pypi.org/project/mashumaro/) based dataclass

airos/airos6.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Ubiquiti AirOS 6."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from http.cookies import SimpleCookie
7+
import json
8+
import logging
9+
from typing import Any
10+
from urllib.parse import urlparse
11+
12+
import aiohttp
13+
from mashumaro.exceptions import InvalidFieldValue, MissingField
14+
15+
from .data import (
16+
AirOS6Data as AirOSData,
17+
DerivedWirelessMode,
18+
DerivedWirelessRole,
19+
redact_data_smart,
20+
)
21+
from .exceptions import (
22+
AirOSConnectionAuthenticationError,
23+
AirOSConnectionSetupError,
24+
AirOSDataMissingError,
25+
AirOSDeviceConnectionError,
26+
AirOSKeyDataMissingError,
27+
)
28+
29+
_LOGGER = logging.getLogger(__name__)
30+
31+
32+
class AirOS:
33+
"""AirOS 6 connection class."""
34+
35+
def __init__(
36+
self,
37+
host: str,
38+
username: str,
39+
password: str,
40+
session: aiohttp.ClientSession,
41+
use_ssl: bool = True,
42+
):
43+
"""Initialize AirOS6 class."""
44+
self.username = username
45+
self.password = password
46+
47+
parsed_host = urlparse(host)
48+
scheme = (
49+
parsed_host.scheme
50+
if parsed_host.scheme
51+
else ("https" if use_ssl else "http")
52+
)
53+
hostname = parsed_host.hostname if parsed_host.hostname else host
54+
55+
self.base_url = f"{scheme}://{hostname}"
56+
57+
self.session = session
58+
59+
self._login_url = f"{self.base_url}/api/auth"
60+
self._status_cgi_url = f"{self.base_url}/status.cgi"
61+
self.current_csrf_token: str | None = None
62+
63+
self._use_json_for_login_post = False
64+
65+
self._auth_cookie: str | None = None
66+
self._csrf_id: str | None = None
67+
self.connected: bool = False
68+
69+
@staticmethod
70+
def derived_data(response: dict[str, Any]) -> dict[str, Any]:
71+
"""Add derived data to the device response."""
72+
derived: dict[str, Any] = {
73+
"station": False,
74+
"access_point": False,
75+
"ptp": False,
76+
"ptmp": False,
77+
"role": DerivedWirelessRole.STATION,
78+
"mode": DerivedWirelessMode.PTP,
79+
}
80+
81+
# Access Point / Station - no info on ptp/ptmp
82+
derived["ptp"] = True
83+
wireless_mode = response.get("wireless", {}).get("mode", "")
84+
match wireless_mode:
85+
case "ap":
86+
derived["access_point"] = True
87+
derived["role"] = DerivedWirelessRole.ACCESS_POINT
88+
case "sta":
89+
derived["station"] = True
90+
91+
# INTERFACES
92+
addresses = {}
93+
interface_order = ["br0", "eth0", "ath0"]
94+
95+
interfaces = response.get("interfaces", [])
96+
97+
# No interfaces, no mac, no usability
98+
if not interfaces:
99+
_LOGGER.error("Failed to determine interfaces from AirOS data")
100+
raise AirOSKeyDataMissingError from None
101+
102+
for interface in interfaces:
103+
if interface["enabled"]: # Only consider if enabled
104+
addresses[interface["ifname"]] = interface["hwaddr"]
105+
106+
# Fallback take fist alternate interface found
107+
derived["mac"] = interfaces[0]["hwaddr"]
108+
derived["mac_interface"] = interfaces[0]["ifname"]
109+
110+
for interface in interface_order:
111+
if interface in addresses:
112+
derived["mac"] = addresses[interface]
113+
derived["mac_interface"] = interface
114+
break
115+
116+
response["derived"] = derived
117+
118+
return response
119+
120+
def _get_authenticated_headers(
121+
self,
122+
ct_json: bool = False,
123+
ct_form: bool = False,
124+
) -> dict[str, str]:
125+
"""Construct headers for an authenticated request."""
126+
headers = {}
127+
if ct_json:
128+
headers["Content-Type"] = "application/json"
129+
elif ct_form:
130+
headers["Content-Type"] = "application/x-www-form-urlencoded"
131+
132+
if self._csrf_id:
133+
headers["X-CSRF-ID"] = self._csrf_id
134+
135+
if self._auth_cookie:
136+
headers["Cookie"] = f"AIROS_{self._auth_cookie}"
137+
138+
return headers
139+
140+
def _store_auth_data(self, response: aiohttp.ClientResponse) -> None:
141+
"""Parse the response from a successful login and store auth data."""
142+
self._csrf_id = response.headers.get("X-CSRF-ID")
143+
144+
# Parse all Set-Cookie headers to ensure we don't miss AIROS_* cookie
145+
cookie = SimpleCookie()
146+
for set_cookie in response.headers.getall("Set-Cookie", []):
147+
cookie.load(set_cookie)
148+
for key, morsel in cookie.items():
149+
if key.startswith("AIROS_"):
150+
self._auth_cookie = morsel.key[6:] + "=" + morsel.value
151+
break
152+
153+
async def _request_json(
154+
self,
155+
method: str,
156+
url: str,
157+
headers: dict[str, Any] | None = None,
158+
json_data: dict[str, Any] | None = None,
159+
form_data: dict[str, Any] | None = None,
160+
authenticated: bool = False,
161+
ct_json: bool = False,
162+
ct_form: bool = False,
163+
) -> dict[str, Any] | Any:
164+
"""Make an authenticated API request and return JSON response."""
165+
# Pass the content type flags to the header builder
166+
request_headers = (
167+
self._get_authenticated_headers(ct_json=ct_json, ct_form=ct_form)
168+
if authenticated
169+
else {}
170+
)
171+
if headers:
172+
request_headers.update(headers)
173+
174+
try:
175+
if url != self._login_url and not self.connected:
176+
_LOGGER.error("Not connected, login first")
177+
raise AirOSDeviceConnectionError from None
178+
179+
async with self.session.request(
180+
method,
181+
url,
182+
json=json_data,
183+
data=form_data,
184+
headers=request_headers, # Pass the constructed headers
185+
) as response:
186+
response.raise_for_status()
187+
response_text = await response.text()
188+
_LOGGER.debug("Successfully fetched JSON from %s", url)
189+
190+
# If this is the login request, we need to store the new auth data
191+
if url == self._login_url:
192+
self._store_auth_data(response)
193+
self.connected = True
194+
195+
return json.loads(response_text)
196+
except aiohttp.ClientResponseError as err:
197+
_LOGGER.error(
198+
"Request to %s failed with status %s: %s", url, err.status, err.message
199+
)
200+
if err.status == 401:
201+
raise AirOSConnectionAuthenticationError from err
202+
raise AirOSConnectionSetupError from err
203+
except (TimeoutError, aiohttp.ClientError) as err:
204+
_LOGGER.exception("Error during API call to %s", url)
205+
raise AirOSDeviceConnectionError from err
206+
except json.JSONDecodeError as err:
207+
_LOGGER.error("Failed to decode JSON from %s", url)
208+
raise AirOSDataMissingError from err
209+
except asyncio.CancelledError:
210+
_LOGGER.warning("Request to %s was cancelled", url)
211+
raise
212+
213+
async def login(self) -> None:
214+
"""Login to AirOS device."""
215+
payload = {"username": self.username, "password": self.password}
216+
try:
217+
await self._request_json("POST", self._login_url, json_data=payload)
218+
except (AirOSConnectionAuthenticationError, AirOSConnectionSetupError) as err:
219+
raise AirOSConnectionSetupError("Failed to login to AirOS device") from err
220+
221+
async def status(self) -> AirOSData:
222+
"""Retrieve status from the device."""
223+
response = await self._request_json(
224+
"GET", self._status_cgi_url, authenticated=True
225+
)
226+
227+
try:
228+
adjusted_json = self.derived_data(response)
229+
return AirOSData.from_dict(adjusted_json)
230+
except InvalidFieldValue as err:
231+
# Log with .error() as this is a specific, known type of issue
232+
redacted_data = redact_data_smart(response)
233+
_LOGGER.error(
234+
"Failed to deserialize AirOS data due to an invalid field value: %s",
235+
redacted_data,
236+
)
237+
raise AirOSKeyDataMissingError from err
238+
except MissingField as err:
239+
# Log with .exception() for a full stack trace
240+
redacted_data = redact_data_smart(response)
241+
_LOGGER.exception(
242+
"Failed to deserialize AirOS data due to a missing field: %s",
243+
redacted_data,
244+
)
245+
raise AirOSKeyDataMissingError from err

airos/airos8.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def derived_data(response: dict[str, Any]) -> dict[str, Any]:
113113

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

118119
for interface in interfaces:

0 commit comments

Comments
 (0)