Skip to content

Commit 868ff1f

Browse files
authored
Merge pull request #112 from CoMPaTech/modelmap
Modelmap
2 parents fdebad1 + 82d1d8c commit 868ff1f

22 files changed

+284
-32
lines changed

CHANGELOG.md

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

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

5+
## [0.5.6] - 2025-10-11
6+
7+
### Added
8+
9+
- Model name (devmodel) to SKU (product code) mapper for model_id and model_name matching in Home Assistant
10+
511
## [0.5.5] - 2025-10-05
612

713
### Changed

airos/base.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@
2626
AirOSDataMissingError,
2727
AirOSDeviceConnectionError,
2828
AirOSKeyDataMissingError,
29+
AirOSMultipleMatchesFoundException,
2930
AirOSUrlNotFoundError,
3031
)
32+
from .model_map import UispAirOSProductMapper
3133

3234
_LOGGER = logging.getLogger(__name__)
3335

@@ -120,15 +122,36 @@ def _derived_data_helper(
120122
],
121123
) -> dict[str, Any]:
122124
"""Add derived data to the device response."""
125+
sku: str = "UNKNOWN"
126+
127+
devmodel = (response.get("host") or {}).get("devmodel", "UNKNOWN")
128+
try:
129+
sku = UispAirOSProductMapper().get_sku_by_devmodel(devmodel)
130+
except KeyError:
131+
_LOGGER.warning(
132+
"Unknown SKU/Model ID for %s. Please report at "
133+
"https://github.com/CoMPaTech/python-airos/issues so we can add support.",
134+
devmodel,
135+
)
136+
sku = "UNKNOWN"
137+
except AirOSMultipleMatchesFoundException as err: # pragma: no cover
138+
_LOGGER.warning(
139+
"Multiple SKU/Model ID matches found for model '%s': %s. Please report at "
140+
"https://github.com/CoMPaTech/python-airos/issues so we can add support.",
141+
devmodel,
142+
err,
143+
)
144+
sku = "AMBIGUOUS"
145+
123146
derived: dict[str, Any] = {
124147
"station": False,
125148
"access_point": False,
126149
"ptp": False,
127150
"ptmp": False,
128151
"role": DerivedWirelessRole.STATION,
129152
"mode": DerivedWirelessMode.PTP,
153+
"sku": sku,
130154
}
131-
132155
# WIRELESS
133156
derived = derived_wireless_data_func(derived, response)
134157

@@ -177,10 +200,10 @@ def _get_authenticated_headers(
177200
elif ct_form:
178201
headers["Content-Type"] = "application/x-www-form-urlencoded"
179202

180-
if self._csrf_id:
203+
if self._csrf_id: # pragma: no cover
181204
headers["X-CSRF-ID"] = self._csrf_id
182205

183-
if self._auth_cookie:
206+
if self._auth_cookie: # pragma: no cover
184207
headers["Cookie"] = f"AIROS_{self._auth_cookie}"
185208

186209
return headers

airos/data.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def is_ip_address(value: str) -> bool:
3737
ipaddress.ip_address(value)
3838
except ValueError:
3939
return False
40-
return True
40+
return True # pragma: no cover
4141

4242

4343
def redact_data_smart(data: dict[str, Any]) -> dict[str, Any]:
@@ -64,18 +64,18 @@ def _redact(d: dict[str, Any]) -> dict[str, Any]:
6464
if isinstance(v, str) and (is_mac_address(v) or is_mac_address_mask(v)):
6565
# Redact only the last part of a MAC address to a dummy value
6666
redacted_d[k] = "00:11:22:33:" + v.replace("-", ":").upper()[-5:]
67-
elif isinstance(v, str) and is_ip_address(v):
67+
elif isinstance(v, str) and is_ip_address(v): # pragma: no cover
6868
# Redact to a dummy local IP address
6969
redacted_d[k] = "127.0.0.3"
7070
elif isinstance(v, list) and all(
7171
isinstance(i, str) and is_ip_address(i) for i in v
72-
):
72+
): # pragma: no cover
7373
# Redact list of IPs to a dummy list
7474
redacted_d[k] = ["127.0.0.3"] # type: ignore[assignment]
7575
elif isinstance(v, list) and all(
7676
isinstance(i, dict) and "addr" in i and is_ip_address(i["addr"])
7777
for i in v
78-
):
78+
): # pragma: no cover
7979
# Redact list of dictionaries with IP addresses to a dummy list
8080
redacted_list = []
8181
for item in v:
@@ -688,6 +688,9 @@ class Derived(AirOSDataClass):
688688
role: DerivedWirelessRole
689689
mode: DerivedWirelessMode
690690

691+
# Lookup of model_id (presumed via SKU)
692+
sku: str
693+
691694

692695
@dataclass
693696
class AirOS8Data(AirOSDataBaseClass):

airos/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ class AirOSNotSupportedError(AirOSException):
4343

4444
class AirOSUrlNotFoundError(AirOSException):
4545
"""Raised when url not available for device."""
46+
47+
48+
class AirOSMultipleMatchesFoundException(AirOSException):
49+
"""Raised when multiple devices found for lookup."""

airos/helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ async def async_get_firmware_data(
5858
hostname = derived_data.get("host", {}).get("hostname")
5959
mac = derived_data.get("derived", {}).get("mac")
6060

61-
if not hostname:
61+
if not hostname: # pragma: no cover
6262
raise AirOSKeyDataMissingError("Missing hostname")
6363

64-
if not mac:
64+
if not mac: # pragma: no cover
6565
raise AirOSKeyDataMissingError("Missing MAC address")
6666

6767
return {

airos/model_map.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""List of airOS products."""
2+
3+
from .exceptions import AirOSMultipleMatchesFoundException
4+
5+
MODELS: dict[str, str] = {
6+
# Generated list from https://store.ui.com/us/en/category/wireless
7+
"Wave MLO5": "Wave-MLO5",
8+
"airMAX Rocket Prism 5AC": "RP-5AC-Gen2",
9+
"airFiber 5XHD": "AF-5XHD",
10+
"airMAX Lite AP GPS": "LAP-GPS",
11+
"airMAX PowerBeam 5AC": "PBE-5AC-Gen2",
12+
"airMAX PowerBeam 5AC ISO": "PBE-5AC-ISO-Gen2",
13+
"airMAX PowerBeam 5AC 620": "PBE-5AC-620",
14+
"airMAX LiteBeam 5AC": "LBE-5AC-GEN2",
15+
"airMAX LiteBeam 5AC Long-Range": "LBE-5AC-LR",
16+
"airMAX NanoBeam 5AC": "NBE-5AC-GEN2",
17+
"airMAX NanoStation 5AC": "NS-5AC",
18+
"airMAX NanoStation 5AC Loco": "Loco5AC",
19+
"LTU Rocket": "LTU-Rocket",
20+
"LTU Instant (5-pack)": "LTU-Instant",
21+
"LTU Pro": "LTU-PRO",
22+
"LTU Long-Range": "LTU-LR",
23+
"LTU Extreme-Range": "LTU-XR",
24+
"airMAX NanoBeam 2AC": "NBE-2AC-13",
25+
"airMAX PowerBeam 2AC 400": "PBE-2AC-400",
26+
"airMAX Rocket AC Lite": "R5AC-LITE",
27+
"airMAX LiteBeam M5": "LBE-M5-23",
28+
"airMAX PowerBeam 5AC 500": "PBE-5AC-500",
29+
"airMAX PrismStation 5AC": "PS-5AC",
30+
"airMAX IsoStation 5AC": "IS-5AC",
31+
"airMAX Lite AP": "LAP-120",
32+
"airMAX PowerBeam M5 400": "PBE-M5-400",
33+
"airMAX PowerBeam M5 300 ISO": "PBE-M5-300-ISO",
34+
"airMAX PowerBeam M5 300": "PBE-M5-300",
35+
"airMAX PowerBeam M2 400": "PBE-M2-400",
36+
"airMAX Bullet AC": "B-DB-AC",
37+
"airMAX Bullet AC IP67": "BulletAC-IP67",
38+
"airMAX Bullet M2": "BulletM2-HP",
39+
"airMAX IsoStation M5": "IS-M5",
40+
"airMAX NanoStation M5": "NSM5",
41+
"airMAX NanoStation M5 loco": "LocoM5",
42+
"airMAX NanoStation M2 loco": "LocoM2",
43+
"UISP Horn": "UISP-Horn",
44+
"UISP Dish": "UISP-Dish",
45+
"UISP Dish Mini": "UISP-Dish-Mini",
46+
"airMAX AC 5 GHz, 31 dBi RocketDish": "RD-5G31-AC",
47+
"airMAX 5 GHz, 30 dBi RocketDish LW": "RD-5G30-LW",
48+
"airMAX AC 5 GHz, 30/34 dBi RocketDish": "RD-5G",
49+
"airPRISM 3x30° HD Sector": "AP-5AC-90-HD",
50+
"airMAX 5 GHz, 16/17 dBi Sector": "AM-5G1",
51+
"airMAX PrismStation Horn": "Horn-5",
52+
"airMAX 5 GHz, 10 dBi Omni": "AMO-5G10",
53+
"airMAX 5 GHz, 13 dBi, Omni": "AMO-5G13",
54+
"airMAX Sector 2.4 GHz Titanium": "AM-V2G-Ti",
55+
"airMAX AC 5 GHz, 21 dBi, 60º Sector": "AM-5AC21-60",
56+
"airMAX AC 5 GHz, 22 dBi, 45º Sector": "AM-5AC22-45",
57+
"airMAX 2.4 GHz, 16 dBi, 90º Sector": "AM-2G16-90",
58+
"airMAX 900 MHz, 13 dBi, 120º Sector": "AM-9M13-120",
59+
"airMAX 900 MHz, 16 dBi Yagi": "AMY-9M16x2",
60+
"airMAX NanoBeam M5": "NBE-M5-16",
61+
"airMAX Rocket Prism 2AC": "R2AC-PRISM",
62+
"airFiber 5 Mid-Band": "AF-5",
63+
"airFiber 5 High-Band": "AF-5U",
64+
"airFiber 24": "AF-24",
65+
"airFiber 24 Hi-Density": "AF-24HD",
66+
"airFiber 2X": "AF-2X",
67+
"airFiber 11": "AF-11",
68+
"airFiber 11 Low-Band Backhaul Radio with Dish Antenna": "AF11-Complete-LB",
69+
"airFiber 11 High-Band Backhaul Radio with Dish Antenna": "AF11-Complete-HB",
70+
"airMAX LiteBeam 5AC Extreme-Range": "LBE-5AC-XR",
71+
"airMAX PowerBeam M5 400 ISO": "PBE-M5-400-ISO",
72+
"airMAX NanoStation M2": "NSM2",
73+
"airFiber X 5 GHz, 23 dBi, Slant 45": "AF-5G23-S45",
74+
"airFiber X 5 GHz, 30 dBi, Slant 45": "AF-5G30-S45",
75+
"airFiber X 5 GHz, 34 dBi, Slant 45": "AF-5G34-S45",
76+
"airMAX 5 GHz, 19/20 dBi Sector": "AM-5G2",
77+
"airMAX 2.4 GHz, 10 dBi Omni": "AMO-2G10",
78+
"airMAX 2.4 GHz, 15 dBi, 120º Sector": "AM-2G15-120",
79+
# Manually added entries for common unofficial names
80+
"LiteAP GPS": "LAP-GPS", # Shortened name for airMAX Lite Access Point GPS
81+
}
82+
83+
84+
class UispAirOSProductMapper:
85+
"""Utility class to map product model names to SKUs and vice versa."""
86+
87+
def __init__(self) -> None:
88+
"""Provide reversed map for SKUs."""
89+
self._SKUS = {v: k for k, v in MODELS.items()}
90+
91+
def get_sku_by_devmodel(self, devmodel: str) -> str:
92+
"""Retrieves the SKU for a given device model name."""
93+
if devmodel in MODELS:
94+
return MODELS[devmodel]
95+
96+
match_key: str | None = None
97+
matches_found: int = 0
98+
99+
best_match_key: str | None = None
100+
best_match_is_prefix = False
101+
102+
lower_devmodel = devmodel.lower()
103+
104+
for model_name in MODELS:
105+
lower_model_name = model_name.lower()
106+
107+
if lower_model_name.endswith(lower_devmodel):
108+
if not best_match_is_prefix or len(lower_model_name) == len(
109+
lower_devmodel
110+
):
111+
best_match_key = model_name
112+
best_match_is_prefix = True
113+
matches_found = 1
114+
match_key = model_name
115+
else:
116+
matches_found += 1
117+
best_match_key = None
118+
119+
elif not best_match_is_prefix and lower_devmodel in lower_model_name:
120+
matches_found += 1
121+
match_key = model_name
122+
123+
if best_match_key and best_match_is_prefix and matches_found == 1:
124+
# If a unique prefix match was found ("LiteBeam 5AC" -> "airMAX LiteBeam 5AC")
125+
return MODELS[best_match_key]
126+
127+
if best_match_key and best_match_is_prefix and matches_found > 1:
128+
pass # fall through exception
129+
130+
if match_key is None or matches_found == 0:
131+
raise KeyError(f"No product found for devmodel: {devmodel}")
132+
133+
if match_key and matches_found == 1:
134+
return MODELS[match_key]
135+
136+
raise AirOSMultipleMatchesFoundException(
137+
f"Partial model '{devmodel}' matched multiple ({matches_found}) products."
138+
)
139+
140+
def get_devmodel_by_sku(self, sku: str) -> str:
141+
"""Retrieves the full device model name for an exact SKU match."""
142+
if sku in self._SKUS:
143+
return self._SKUS[sku]
144+
raise KeyError(f"No product found for SKU: {sku}")

fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"ptmp": false,
1818
"ptp": true,
1919
"role": "access_point",
20+
"sku": "LBE-5AC-GEN2",
2021
"station": false
2122
},
2223
"firewall": {

fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"ptmp": false,
1818
"ptp": true,
1919
"role": "station",
20+
"sku": "LBE-5AC-GEN2",
2021
"station": true
2122
},
2223
"firewall": {

fixtures/airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"ptmp": true,
1818
"ptp": false,
1919
"role": "access_point",
20+
"sku": "NBE-5AC-GEN2",
2021
"station": false
2122
},
2223
"firewall": {

fixtures/airos_NanoStation_M5_sta_v6.3.16.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"ptmp": false,
1111
"ptp": true,
1212
"role": "station",
13+
"sku": "LocoM5",
1314
"station": true
1415
},
1516
"firewall": {

0 commit comments

Comments
 (0)