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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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

## [0.5.6] - 2025-10-11

### Added

- Model name (devmodel) to SKU (product code) mapper for model_id and model_name matching in Home Assistant

## [0.5.5] - 2025-10-05

### Changed
Expand Down
19 changes: 16 additions & 3 deletions airos/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSKeyDataMissingError,
AirOSMultipleMatchesFoundException,
AirOSUrlNotFoundError,
)
from .model_map import UispAirOSProductMapper

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -120,15 +122,26 @@ def _derived_data_helper(
],
) -> dict[str, Any]:
"""Add derived data to the device response."""
sku: str = "UNKNOWN"

devmodel = (response.get("host") or {}).get("devmodel", "UNKNOWN")
try:
sku = UispAirOSProductMapper().get_sku_by_devmodel(devmodel)
except KeyError:
sku = "UNKNOWN"
except AirOSMultipleMatchesFoundException as err: # pragma: no cover
_LOGGER.warning("Multiple matches found for model '%s': %s", devmodel, err)
sku = "AMBIGUOUS"

derived: dict[str, Any] = {
"station": False,
"access_point": False,
"ptp": False,
"ptmp": False,
"role": DerivedWirelessRole.STATION,
"mode": DerivedWirelessMode.PTP,
"sku": sku,
}

# WIRELESS
derived = derived_wireless_data_func(derived, response)

Expand Down Expand Up @@ -177,10 +190,10 @@ def _get_authenticated_headers(
elif ct_form:
headers["Content-Type"] = "application/x-www-form-urlencoded"

if self._csrf_id:
if self._csrf_id: # pragma: no cover
headers["X-CSRF-ID"] = self._csrf_id

if self._auth_cookie:
if self._csrf_id: # pragma: no cover
headers["Cookie"] = f"AIROS_{self._auth_cookie}"

return headers
Expand Down
11 changes: 7 additions & 4 deletions airos/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def is_ip_address(value: str) -> bool:
ipaddress.ip_address(value)
except ValueError:
return False
return True
return True # pragma: no cover


def redact_data_smart(data: dict[str, Any]) -> dict[str, Any]:
Expand All @@ -64,18 +64,18 @@ def _redact(d: dict[str, Any]) -> dict[str, Any]:
if isinstance(v, str) and (is_mac_address(v) or is_mac_address_mask(v)):
# Redact only the last part of a MAC address to a dummy value
redacted_d[k] = "00:11:22:33:" + v.replace("-", ":").upper()[-5:]
elif isinstance(v, str) and is_ip_address(v):
elif isinstance(v, str) and is_ip_address(v): # pragma: no cover
# Redact to a dummy local IP address
redacted_d[k] = "127.0.0.3"
elif isinstance(v, list) and all(
isinstance(i, str) and is_ip_address(i) for i in v
):
): # pragma: no cover
# Redact list of IPs to a dummy list
redacted_d[k] = ["127.0.0.3"] # type: ignore[assignment]
elif isinstance(v, list) and all(
isinstance(i, dict) and "addr" in i and is_ip_address(i["addr"])
for i in v
):
): # pragma: no cover
# Redact list of dictionaries with IP addresses to a dummy list
redacted_list = []
for item in v:
Expand Down Expand Up @@ -688,6 +688,9 @@ class Derived(AirOSDataClass):
role: DerivedWirelessRole
mode: DerivedWirelessMode

# Lookup of model_id (presumed via SKU)
sku: str


@dataclass
class AirOS8Data(AirOSDataBaseClass):
Expand Down
4 changes: 4 additions & 0 deletions airos/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ class AirOSNotSupportedError(AirOSException):

class AirOSUrlNotFoundError(AirOSException):
"""Raised when url not available for device."""


class AirOSMultipleMatchesFoundException(AirOSException):
"""Raised when multiple devices found for lookup."""
4 changes: 2 additions & 2 deletions airos/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ async def async_get_firmware_data(
hostname = derived_data.get("host", {}).get("hostname")
mac = derived_data.get("derived", {}).get("mac")

if not hostname:
if not hostname: # pragma: no cover
raise AirOSKeyDataMissingError("Missing hostname")

if not mac:
if not mac: # pragma: no cover
raise AirOSKeyDataMissingError("Missing MAC address")

return {
Expand Down
144 changes: 144 additions & 0 deletions airos/model_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""List of airOS products."""

from .exceptions import AirOSMultipleMatchesFoundException

MODELS: dict[str, str] = {
# Generated list from https://store.ui.com/us/en/category/wireless
"Wave MLO5": "Wave-MLO5",
"airMAX Rocket Prism 5AC": "RP-5AC-Gen2",
"airFiber 5XHD": "AF-5XHD",
"airMAX Lite AP GPS": "LAP-GPS",
"airMAX PowerBeam 5AC": "PBE-5AC-Gen2",
"airMAX PowerBeam 5AC ISO": "PBE-5AC-ISO-Gen2",
"airMAX PowerBeam 5AC 620": "PBE-5AC-620",
"airMAX LiteBeam 5AC": "LBE-5AC-GEN2",
"airMAX LiteBeam 5AC Long-Range": "LBE-5AC-LR",
"airMAX NanoBeam 5AC": "NBE-5AC-GEN2",
"airMAX NanoStation 5AC": "NS-5AC",
"airMAX NanoStation 5AC Loco": "Loco5AC",
"LTU Rocket": "LTU-Rocket",
"LTU Instant (5-pack)": "LTU-Instant",
"LTU Pro": "LTU-PRO",
"LTU Long-Range": "LTU-LR",
"LTU Extreme-Range": "LTU-XR",
"airMAX NanoBeam 2AC": "NBE-2AC-13",
"airMAX PowerBeam 2AC 400": "PBE-2AC-400",
"airMAX Rocket AC Lite": "R5AC-LITE",
"airMAX LiteBeam M5": "LBE-M5-23",
"airMAX PowerBeam 5AC 500": "PBE-5AC-500",
"airMAX PrismStation 5AC": "PS-5AC",
"airMAX IsoStation 5AC": "IS-5AC",
"airMAX Lite AP": "LAP-120",
"airMAX PowerBeam M5 400": "PBE-M5-400",
"airMAX PowerBeam M5 300 ISO": "PBE-M5-300-ISO",
"airMAX PowerBeam M5 300": "PBE-M5-300",
"airMAX PowerBeam M2 400": "PBE-M2-400",
"airMAX Bullet AC": "B-DB-AC",
"airMAX Bullet AC IP67": "BulletAC-IP67",
"airMAX Bullet M2": "BulletM2-HP",
"airMAX IsoStation M5": "IS-M5",
"airMAX NanoStation M5": "NSM5",
"airMAX NanoStation M5 loco": "LocoM5",
"airMAX NanoStation M2 loco": "LocoM2",
"UISP Horn": "UISP-Horn",
"UISP Dish": "UISP-Dish",
"UISP Dish Mini": "UISP-Dish-Mini",
"airMAX AC 5 GHz, 31 dBi RocketDish": "RD-5G31-AC",
"airMAX 5 GHz, 30 dBi RocketDish LW": "RD-5G30-LW",
"airMAX AC 5 GHz, 30/34 dBi RocketDish": "RD-5G",
"airPRISM 3x30° HD Sector": "AP-5AC-90-HD",
"airMAX 5 GHz, 16/17 dBi Sector": "AM-5G1",
"airMAX PrismStation Horn": "Horn-5",
"airMAX 5 GHz, 10 dBi Omni": "AMO-5G10",
"airMAX 5 GHz, 13 dBi, Omni": "AMO-5G13",
"airMAX Sector 2.4 GHz Titanium": "AM-V2G-Ti",
"airMAX AC 5 GHz, 21 dBi, 60º Sector": "AM-5AC21-60",
"airMAX AC 5 GHz, 22 dBi, 45º Sector": "AM-5AC22-45",
"airMAX 2.4 GHz, 16 dBi, 90º Sector": "AM-2G16-90",
"airMAX 900 MHz, 13 dBi, 120º Sector": "AM-9M13-120",
"airMAX 900 MHz, 16 dBi Yagi": "AMY-9M16x2",
"airMAX NanoBeam M5": "NBE-M5-16",
"airMAX Rocket Prism 2AC": "R2AC-PRISM",
"airFiber 5 Mid-Band": "AF-5",
"airFiber 5 High-Band": "AF-5U",
"airFiber 24": "AF-24",
"airFiber 24 Hi-Density": "AF-24HD",
"airFiber 2X": "AF-2X",
"airFiber 11": "AF-11",
"airFiber 11 Low-Band Backhaul Radio with Dish Antenna": "AF11-Complete-LB",
"airFiber 11 High-Band Backhaul Radio with Dish Antenna": "AF11-Complete-HB",
"airMAX LiteBeam 5AC Extreme-Range": "LBE-5AC-XR",
"airMAX PowerBeam M5 400 ISO": "PBE-M5-400-ISO",
"airMAX NanoStation M2": "NSM2",
"airFiber X 5 GHz, 23 dBi, Slant 45": "AF-5G23-S45",
"airFiber X 5 GHz, 30 dBi, Slant 45": "AF-5G30-S45",
"airFiber X 5 GHz, 34 dBi, Slant 45": "AF-5G34-S45",
"airMAX 5 GHz, 19/20 dBi Sector": "AM-5G2",
"airMAX 2.4 GHz, 10 dBi Omni": "AMO-2G10",
"airMAX 2.4 GHz, 15 dBi, 120º Sector": "AM-2G15-120",
# Manually added entries for common unofficial names
"LiteAP GPS": "LAP-GPS", # Shortened name for airMAX Lite Access Point GPS
}


class UispAirOSProductMapper:
"""Utility class to map product model names to SKUs and vice versa."""

def __init__(self) -> None:
"""Provide reversed map for SKUs."""
self._SKUS = {v: k for k, v in MODELS.items()}

def get_sku_by_devmodel(self, devmodel: str) -> str:
"""Retrieves the SKU for a given device model name."""
if devmodel in MODELS:
return MODELS[devmodel]

match_key: str | None = None
matches_found: int = 0

best_match_key: str | None = None
best_match_is_prefix = False

lower_devmodel = devmodel.lower()

for model_name in MODELS:
lower_model_name = model_name.lower()

if lower_model_name.endswith(lower_devmodel):
if not best_match_is_prefix or len(lower_model_name) == len(
lower_devmodel
):
best_match_key = model_name
best_match_is_prefix = True
matches_found = 1
match_key = model_name
else:
matches_found += 1
best_match_key = None

elif not best_match_is_prefix and lower_devmodel in lower_model_name:
matches_found += 1
match_key = model_name

if best_match_key and best_match_is_prefix and matches_found == 1:
# If a unique prefix match was found ("LiteBeam 5AC" -> "airMAX LiteBeam 5AC")
return MODELS[best_match_key]

if best_match_key and best_match_is_prefix and matches_found > 1:
pass # fall through exception

if match_key is None or matches_found == 0:
raise KeyError(f"No product found for devmodel: {devmodel}")

if match_key and matches_found == 1:
return MODELS[match_key]

raise AirOSMultipleMatchesFoundException(
f"Partial model '{devmodel}' matched multiple ({matches_found}) products."
)

def get_devmodel_by_sku(self, sku: str) -> str:
"""Retrieves the full device model name for an exact SKU match."""
if sku in self._SKUS:
return self._SKUS[sku]
raise KeyError(f"No product found for SKU: {sku}")
1 change: 1 addition & 0 deletions fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": false,
"ptp": true,
"role": "access_point",
"sku": "LBE-5AC-GEN2",
"station": false
},
"firewall": {
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": false,
"ptp": true,
"role": "station",
"sku": "LBE-5AC-GEN2",
"station": true
},
"firewall": {
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": true,
"ptp": false,
"role": "access_point",
"sku": "NBE-5AC-GEN2",
"station": false
},
"firewall": {
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_NanoStation_M5_sta_v6.3.16.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"ptmp": false,
"ptp": true,
"role": "station",
"sku": "LocoM5",
"station": true
},
"firewall": {
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_liteapgps_ap_ptmp_40mhz.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": true,
"ptp": false,
"role": "access_point",
"sku": "LAP-GPS",
"station": false
},
"firewall": {
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_loco5ac_ap-ptp.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": false,
"ptp": true,
"role": "access_point",
"sku": "Loco5AC",
"station": false
},
"firewall": {
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_loco5ac_sta-ptp.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": false,
"ptp": true,
"role": "station",
"sku": "Loco5AC",
"station": true
},
"firewall": {
Expand Down
16 changes: 12 additions & 4 deletions fixtures/airos_mocked_sta-ptmp.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
"access_point": false,
"mac": "01:23:45:67:89:CD",
"mac_interface": "br0",
"mode": "point_to_multipoint",
"ptmp": true,
"ptp": false,
"role": "station",
"sku": "UNKNOWN",
"station": true
},
"firewall": {
Expand All @@ -25,14 +28,19 @@
},
"genuine": "/images/genuine.png",
"gps": {
"alt": 248.6,
"dim": 3,
"dop": 0.91,
"fix": 0,
"lat": 52.379894,
"lon": 4.901608
"lon": 4.901608,
"sats": 9,
"time_synced": 0
},
"host": {
"cpuload": 44.0,
"device_id": "d4f4cdf82961e619328a8f72f8d7653b",
"devmodel": "NanoStation 5AC loco",
"devmodel": "NanoStation 5AC loco unexisting",
"freeram": 16105472,
"fwversion": "v8.7.17",
"height": 2,
Expand Down Expand Up @@ -541,7 +549,7 @@
"netrole": "bridge",
"noisefloor": -89,
"oob": false,
"platform": "NanoStation 5AC loco",
"platform": "NanoStation 5AC loco unexisting",
"power_time": 268736,
"rssi": 36,
"rx_bytes": 207021597130,
Expand Down Expand Up @@ -971,7 +979,7 @@
"netrole": "bridge",
"noisefloor": -89,
"oob": false,
"platform": "NanoStation 5AC loco",
"platform": "NanoStation 5AC loco unexisting",
"power_time": 268736,
"rssi": 36,
"rx_bytes": 207021597130,
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_nanobeam5ac_sta_ptmp_40mhz.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": true,
"ptp": false,
"role": "station",
"sku": "NBE-5AC-GEN2",
"station": true
},
"firewall": {
Expand Down
1 change: 1 addition & 0 deletions fixtures/airos_nanostation_ap-ptp_8718_missing_gps.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ptmp": false,
"ptp": true,
"role": "access_point",
"sku": "Loco5AC",
"station": false
},
"firewall": {
Expand Down
Loading