Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 26 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,36 @@ 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:
_LOGGER.warning(
"Unknown SKU/Model ID for %s. Please report at "
"https://github.com/CoMPaTech/python-airos/issues so we can add support.",
devmodel,
)
sku = "UNKNOWN"
except AirOSMultipleMatchesFoundException as err: # pragma: no cover
_LOGGER.warning(
"Multiple SKU/Model ID matches found for model '%s': %s. Please report at "
"https://github.com/CoMPaTech/python-airos/issues so we can add support.",
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 +200,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._auth_cookie: # 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
Loading