Skip to content

Commit 2b1889b

Browse files
committed
Add model mapper
1 parent fdebad1 commit 2b1889b

File tree

5 files changed

+187
-7
lines changed

5 files changed

+187
-7
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/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/model_map.py

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

pyproject.toml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "airos"
7-
version = "0.5.5"
7+
version = "0.5.6"
88
license = "MIT"
99
description = "Ubiquiti airOS module(s) for Python 3."
1010
readme = "README.md"
@@ -341,12 +341,6 @@ enable = [
341341
#"useless-suppression", # temporarily every now and then to clean them up
342342
"use-symbolic-message-instead",
343343
]
344-
per-file-ignores = [
345-
# redefined-outer-name: Tests reference fixtures in the test function
346-
# use-implicit-booleaness-not-comparison: Tests need to validate that a list
347-
# or a dict is returned
348-
"/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison",
349-
]
350344

351345
[tool.pylint.REPORTS]
352346
score = false

tests/test_model_map.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Unit tests for the UispAirOSProductMapper class in model_map.py."""
2+
3+
import re
4+
5+
import pytest
6+
7+
from airos.exceptions import AirOSMultipleMatchesFoundException
8+
from airos.model_map import UispAirOSProductMapper
9+
10+
11+
class TestUispAirOSProductMapper:
12+
"""Unit tests for the UispAirOSProductMapper class."""
13+
14+
mapper = UispAirOSProductMapper()
15+
16+
def test_get_sku_by_devmodel_exact_match(self):
17+
"""Test to return the correct SKU for a full, exact model name."""
18+
sku = self.mapper.get_sku_by_devmodel("Wave MLO5")
19+
assert sku == "Wave-MLO5"
20+
21+
def test_get_sku_by_devmodel_partial_single_match(self):
22+
"""Test to return the correct SKU for a partial model name that matches only one product."""
23+
sku = self.mapper.get_sku_by_devmodel("NanoBeam 5AC")
24+
assert sku == "NBE-5AC-GEN2"
25+
26+
def test_get_sku_by_devmodel_case_insensitivity(self):
27+
"""Test to work regardless of the case of the input model name."""
28+
sku = self.mapper.get_sku_by_devmodel("nanostation 5ac loco")
29+
assert sku == "Loco5AC"
30+
31+
def test_get_sku_by_devmodel_not_found_raises_keyerror(self):
32+
"""Test to raise KeyError when no match (exact or partial) is found."""
33+
with pytest.raises(
34+
KeyError, match="No product found for devmodel: NonExistent"
35+
):
36+
self.mapper.get_sku_by_devmodel("NonExistent Model 123")
37+
38+
def test_get_sku_by_devmodel_multiple_matches_raises_exception_dynamic(self):
39+
"""Test to raise AirOSMultipleMatchesFoundException when partial match is ambiguous."""
40+
with pytest.raises(AirOSMultipleMatchesFoundException) as excinfo:
41+
self.mapper.get_sku_by_devmodel("Rocket")
42+
43+
exception_message = str(excinfo.value)
44+
expected_matches = 7
45+
46+
match = re.search(r"matched multiple \((\d+)\) products", exception_message)
47+
assert match is not None
48+
actual_matches_int = int(match.group(1))
49+
assert actual_matches_int == expected_matches
50+
51+
def test_get_devmodel_by_sku_exact_match(self):
52+
"""Test to return the full model name for an exact SKU."""
53+
model = self.mapper.get_devmodel_by_sku("Loco5AC")
54+
assert model == "airMAX NanoStation 5AC Loco"
55+
56+
def test_get_devmodel_by_sku_not_found_raises_keyerror(self):
57+
"""Test to raise KeyError when the exact SKU is not found."""
58+
with pytest.raises(KeyError, match="No product found for SKU: FAKE-SKU"):
59+
self.mapper.get_devmodel_by_sku("FAKE-SKU")

0 commit comments

Comments
 (0)