Skip to content

Commit b083ace

Browse files
committed
Rework detection
1 parent 51d053e commit b083ace

21 files changed

+107
-207
lines changed

.github/workflows/verify.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ jobs:
174174
run: |
175175
. venv/bin/activate
176176
coverage combine artifacts/.coverage*
177-
coverage report --fail-under=85
177+
coverage report --fail-under=80
178178
coverage xml
179179
- name: Upload coverage to Codecov
180180
uses: codecov/codecov-action@v5

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ All notable changes to this project will be documented in this file.
1212
- Added tx/rx rates for v6
1313
- Fixed ieeemode (v8) vs opmode (v6) mapped back to IeeeMode enum
1414
- Derived antenna_gain (v8) from antenna (v6 string)
15-
- Improved internal workings
15+
- Improved internal workings and firmware detection
1616

1717
## [0.5.6] - 2025-10-11
1818

airos/base.py

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -158,38 +158,67 @@ def _derived_data_helper(
158158
"mode": DerivedWirelessMode.PTP,
159159
"sku": sku,
160160
}
161+
161162
# WIRELESS
162163
derived = derived_wireless_data_func(derived, response)
163164

164-
# INTERFACES
165-
addresses = {}
166-
interface_order = ["br0", "eth0", "ath0"]
167-
165+
# Interfaces / MAC (for unique id)
168166
interfaces = response.get("interfaces", [])
169-
170167
# No interfaces, no mac, no usability
171168
if not interfaces:
172169
_LOGGER.error("Failed to determine interfaces from AirOS data")
173170
raise AirOSKeyDataMissingError from None
174171

175-
for interface in interfaces:
176-
if interface["enabled"]: # Only consider if enabled
177-
addresses[interface["ifname"]] = interface["hwaddr"]
172+
derived["mac"] = AirOS.get_mac(interfaces)["mac"]
173+
derived["mac_interface"] = AirOS.get_mac(interfaces)["mac_interface"]
178174

179-
# Fallback take fist alternate interface found
180-
derived["mac"] = interfaces[0]["hwaddr"]
181-
derived["mac_interface"] = interfaces[0]["ifname"]
182-
183-
for interface in interface_order:
184-
if interface in addresses:
185-
derived["mac"] = addresses[interface]
186-
derived["mac_interface"] = interface
187-
break
175+
# Firmware Major Version
176+
fwversion = (response.get("host") or {}).get("fwversion", "invalid")
177+
derived["fw_major"] = AirOS.get_fw_major(fwversion)
188178

189179
response["derived"] = derived
190180

191181
return response
192182

183+
@staticmethod
184+
def get_fw_major(fwversion: str) -> int:
185+
"""Extract major firmware version from fwversion string."""
186+
try:
187+
return int(fwversion.lstrip("v").split(".", 1)[0])
188+
except (ValueError, AttributeError) as err:
189+
_LOGGER.error("Invalid firmware version '%s'", fwversion)
190+
raise AirOSKeyDataMissingError("invalid fwversion") from err
191+
192+
@staticmethod
193+
def get_mac(interfaces: list[dict[str, Any]]) -> dict[str, str]:
194+
"""Extract MAC address from interfaces."""
195+
result: dict[str, str] = {"mac": "", "mac_interface": ""}
196+
197+
if not interfaces:
198+
return result
199+
200+
addresses: dict[str, str] = {}
201+
interface_order = ["br0", "eth0", "ath0"]
202+
203+
for interface in interfaces:
204+
if (
205+
interface.get("enabled")
206+
and interface.get("hwaddr")
207+
and interface.get("ifname")
208+
):
209+
addresses[interface["ifname"]] = interface["hwaddr"]
210+
211+
for preferred in interface_order:
212+
if preferred in addresses:
213+
result["mac"] = addresses[preferred]
214+
result["mac_interface"] = preferred
215+
break
216+
else:
217+
result["mac"] = interfaces[0].get("hwaddr", "")
218+
result["mac_interface"] = interfaces[0].get("ifname", "")
219+
220+
return result
221+
193222
@classmethod
194223
def derived_data(cls, response: dict[str, Any]) -> dict[str, Any]:
195224
"""Add derived data to the device response (instance method for polymorphism)."""
@@ -354,12 +383,15 @@ async def login(self) -> None:
354383
else:
355384
return
356385

357-
async def status(self) -> AirOSDataModel:
386+
async def status(self, underived: bool = False) -> AirOSDataModel | dict[str, Any]:
358387
"""Retrieve status from the device."""
359388
response = await self._request_json(
360389
"GET", self._status_cgi_url, authenticated=True
361390
)
362391

392+
if underived:
393+
return response
394+
363395
try:
364396
adjusted_json = self.derived_data(response)
365397
return self.data_model.from_dict(adjusted_json)

airos/data.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,6 @@ def _redact(d: dict[str, Any]) -> dict[str, Any]:
102102
return _redact(data)
103103

104104

105-
# Data class start
106-
107-
108105
class AirOSDataClass(DataClassDictMixin):
109106
"""A base class for all mashumaro dataclasses."""
110107

@@ -732,6 +729,9 @@ class Derived(AirOSDataClass):
732729
# Lookup of model_id (presumed via SKU)
733730
sku: str
734731

732+
# Firmware major version
733+
fw_major: int | None = None
734+
735735

736736
@dataclass
737737
class AirOS8Data(AirOSDataBaseClass):

airos/helpers.py

Lines changed: 30 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
"""Ubiquiti AirOS firmware helpers."""
22

3+
import logging
34
from typing import TypedDict
45

56
import aiohttp
67

7-
from .airos6 import AirOS6
88
from .airos8 import AirOS8
9-
from .exceptions import AirOSKeyDataMissingError
9+
from .exceptions import (
10+
AirOSConnectionAuthenticationError,
11+
AirOSConnectionSetupError,
12+
AirOSDataMissingError,
13+
AirOSDeviceConnectionError,
14+
AirOSKeyDataMissingError,
15+
)
16+
17+
_LOGGER = logging.getLogger(__name__)
1018

1119

1220
class DetectDeviceData(TypedDict):
@@ -25,49 +33,28 @@ async def async_get_firmware_data(
2533
use_ssl: bool = True,
2634
) -> DetectDeviceData:
2735
"""Connect to a device and return the major firmware version."""
28-
detect: AirOS8 = AirOS8(host, username, password, session, use_ssl)
29-
30-
await detect.login()
31-
raw_status = await detect._request_json( # noqa: SLF001
32-
"GET",
33-
detect._status_cgi_url, # noqa: SLF001
34-
authenticated=True,
35-
)
36-
37-
fw_version = (raw_status.get("host") or {}).get("fwversion")
38-
if not fw_version:
39-
raise AirOSKeyDataMissingError("Missing host.fwversion in API response")
36+
detect_device: AirOS8 = AirOS8(host, username, password, session, use_ssl)
4037

4138
try:
42-
fw_major = int(fw_version.lstrip("v").split(".", 1)[0])
43-
except (ValueError, AttributeError) as exc:
44-
raise AirOSKeyDataMissingError(
45-
f"Invalid firmware version '{fw_version}'"
46-
) from exc
47-
48-
if fw_major == 6:
49-
derived_data = AirOS6._derived_data_helper( # noqa: SLF001
50-
raw_status,
51-
AirOS6._derived_wireless_data, # noqa: SLF001
52-
)
53-
else: # Assume AirOS 8 for all other versions
54-
derived_data = AirOS8._derived_data_helper( # noqa: SLF001
55-
raw_status,
56-
AirOS8._derived_wireless_data, # noqa: SLF001
57-
)
58-
59-
# Extract MAC address and hostname from the derived data
60-
hostname = derived_data.get("host", {}).get("hostname")
61-
mac = derived_data.get("derived", {}).get("mac")
62-
63-
if not hostname: # pragma: no cover
64-
raise AirOSKeyDataMissingError("Missing hostname")
65-
66-
if not mac: # pragma: no cover
67-
raise AirOSKeyDataMissingError("Missing MAC address")
39+
await detect_device.login()
40+
device_data = await detect_device.status(underived=True)
41+
except (
42+
AirOSConnectionSetupError,
43+
AirOSDeviceConnectionError,
44+
):
45+
_LOGGER.exception("Error connecting to device at %s", host)
46+
raise
47+
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
48+
_LOGGER.exception("Authentication error connecting to device at %s", host)
49+
raise
50+
except AirOSKeyDataMissingError:
51+
_LOGGER.exception("Key data missing from device at %s", host)
52+
raise
53+
54+
assert isinstance(device_data, dict)
6855

6956
return {
70-
"fw_major": fw_major,
71-
"mac": mac,
72-
"hostname": hostname,
57+
"fw_major": AirOS8.get_fw_major(device_data.get("host", {}).get("fwversion")),
58+
"hostname": device_data.get("host", {}).get("hostname"),
59+
"mac": AirOS8.get_mac(device_data.get("interfaces", {}))["mac"],
7360
}

fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
],
1212
"derived": {
1313
"access_point": true,
14+
"fw_major": 8,
1415
"mac": "68:D7:9A:9A:08:BB",
1516
"mac_interface": "br0",
1617
"mode": "point_to_point",

fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
],
1212
"derived": {
1313
"access_point": false,
14+
"fw_major": 8,
1415
"mac": "68:D7:9A:98:FB:FF",
1516
"mac_interface": "br0",
1617
"mode": "point_to_point",

fixtures/airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
],
1212
"derived": {
1313
"access_point": true,
14+
"fw_major": 8,
1415
"mac": "xxxxxxxxxxxxxxxx",
1516
"mac_interface": "br0",
1617
"mode": "point_to_multipoint",

fixtures/airos_NanoStation_M5_sta_v6.3.16.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
},
55
"derived": {
66
"access_point": false,
7+
"fw_major": 6,
78
"mac": "XX:XX:XX:XX:XX:XX",
89
"mac_interface": "br0",
910
"mode": "point_to_point",

fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_ap.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
},
55
"derived": {
66
"access_point": true,
7+
"fw_major": 6,
78
"mac": "XX:XX:XX:XX:XX:XX",
89
"mac_interface": "br0",
910
"mode": "point_to_point",

0 commit comments

Comments
 (0)