From 2b1889b6d2994f95260cde7becfa11103f1b283a Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 11 Oct 2025 22:40:05 +0200 Subject: [PATCH 1/6] Add model mapper --- CHANGELOG.md | 6 +++ airos/exceptions.py | 4 ++ airos/model_map.py | 117 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 +-- tests/test_model_map.py | 59 ++++++++++++++++++++ 5 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 airos/model_map.py create mode 100644 tests/test_model_map.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0284e22..7369fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/airos/exceptions.py b/airos/exceptions.py index 339d6fd..8783a0e 100644 --- a/airos/exceptions.py +++ b/airos/exceptions.py @@ -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.""" diff --git a/airos/model_map.py b/airos/model_map.py new file mode 100644 index 0000000..df9ff5e --- /dev/null +++ b/airos/model_map.py @@ -0,0 +1,117 @@ +"""List of airOS products.""" + +from .exceptions import AirOSMultipleMatchesFoundException + +MODELS: dict[str, str] = { + "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", +} + + +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 = None + matches_found = 0 + + lower_devmodel = devmodel.lower() + + for model_name in MODELS: + if lower_devmodel in model_name.lower(): + match_key = model_name + matches_found += 1 + + 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}") diff --git a/pyproject.toml b/pyproject.toml index 315090c..f2b1ea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.5" +version = "0.5.6" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" @@ -341,12 +341,6 @@ enable = [ #"useless-suppression", # temporarily every now and then to clean them up "use-symbolic-message-instead", ] -per-file-ignores = [ - # redefined-outer-name: Tests reference fixtures in the test function - # use-implicit-booleaness-not-comparison: Tests need to validate that a list - # or a dict is returned - "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", -] [tool.pylint.REPORTS] score = false diff --git a/tests/test_model_map.py b/tests/test_model_map.py new file mode 100644 index 0000000..3d08936 --- /dev/null +++ b/tests/test_model_map.py @@ -0,0 +1,59 @@ +"""Unit tests for the UispAirOSProductMapper class in model_map.py.""" + +import re + +import pytest + +from airos.exceptions import AirOSMultipleMatchesFoundException +from airos.model_map import UispAirOSProductMapper + + +class TestUispAirOSProductMapper: + """Unit tests for the UispAirOSProductMapper class.""" + + mapper = UispAirOSProductMapper() + + def test_get_sku_by_devmodel_exact_match(self): + """Test to return the correct SKU for a full, exact model name.""" + sku = self.mapper.get_sku_by_devmodel("Wave MLO5") + assert sku == "Wave-MLO5" + + def test_get_sku_by_devmodel_partial_single_match(self): + """Test to return the correct SKU for a partial model name that matches only one product.""" + sku = self.mapper.get_sku_by_devmodel("NanoBeam 5AC") + assert sku == "NBE-5AC-GEN2" + + def test_get_sku_by_devmodel_case_insensitivity(self): + """Test to work regardless of the case of the input model name.""" + sku = self.mapper.get_sku_by_devmodel("nanostation 5ac loco") + assert sku == "Loco5AC" + + def test_get_sku_by_devmodel_not_found_raises_keyerror(self): + """Test to raise KeyError when no match (exact or partial) is found.""" + with pytest.raises( + KeyError, match="No product found for devmodel: NonExistent" + ): + self.mapper.get_sku_by_devmodel("NonExistent Model 123") + + def test_get_sku_by_devmodel_multiple_matches_raises_exception_dynamic(self): + """Test to raise AirOSMultipleMatchesFoundException when partial match is ambiguous.""" + with pytest.raises(AirOSMultipleMatchesFoundException) as excinfo: + self.mapper.get_sku_by_devmodel("Rocket") + + exception_message = str(excinfo.value) + expected_matches = 7 + + match = re.search(r"matched multiple \((\d+)\) products", exception_message) + assert match is not None + actual_matches_int = int(match.group(1)) + assert actual_matches_int == expected_matches + + def test_get_devmodel_by_sku_exact_match(self): + """Test to return the full model name for an exact SKU.""" + model = self.mapper.get_devmodel_by_sku("Loco5AC") + assert model == "airMAX NanoStation 5AC Loco" + + def test_get_devmodel_by_sku_not_found_raises_keyerror(self): + """Test to raise KeyError when the exact SKU is not found.""" + with pytest.raises(KeyError, match="No product found for SKU: FAKE-SKU"): + self.mapper.get_devmodel_by_sku("FAKE-SKU") From 10f0989aa90e81a5b4830860106fd738e1b88a8f Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 11 Oct 2025 23:13:16 +0200 Subject: [PATCH 2/6] Consume mapper --- airos/base.py | 19 +++++++++-- airos/data.py | 11 +++--- airos/helpers.py | 4 +-- airos/model_map.py | 34 ++++++++++++++++--- fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json | 1 + fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json | 1 + .../airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json | 1 + .../airos_NanoStation_M5_sta_v6.3.16.json | 1 + fixtures/airos_liteapgps_ap_ptmp_40mhz.json | 1 + fixtures/airos_loco5ac_ap-ptp.json | 1 + fixtures/airos_loco5ac_sta-ptp.json | 1 + .../airos_nanobeam5ac_sta_ptmp_40mhz.json | 1 + ...s_nanostation_ap-ptp_8718_missing_gps.json | 1 + tests/test_stations.py | 19 ++++++----- 14 files changed, 74 insertions(+), 22 deletions(-) diff --git a/airos/base.py b/airos/base.py index b2ca82c..f72d033 100644 --- a/airos/base.py +++ b/airos/base.py @@ -26,8 +26,10 @@ AirOSDataMissingError, AirOSDeviceConnectionError, AirOSKeyDataMissingError, + AirOSMultipleMatchesFoundException, AirOSUrlNotFoundError, ) +from .model_map import UispAirOSProductMapper _LOGGER = logging.getLogger(__name__) @@ -120,6 +122,17 @@ 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, @@ -127,8 +140,8 @@ def _derived_data_helper( "ptmp": False, "role": DerivedWirelessRole.STATION, "mode": DerivedWirelessMode.PTP, + "sku": sku, } - # WIRELESS derived = derived_wireless_data_func(derived, response) @@ -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 diff --git a/airos/data.py b/airos/data.py index 92f1181..cf2f4f2 100644 --- a/airos/data.py +++ b/airos/data.py @@ -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]: @@ -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: @@ -688,6 +688,9 @@ class Derived(AirOSDataClass): role: DerivedWirelessRole mode: DerivedWirelessMode + # Lookup of model_id (presumed via SKU) + sku: str + @dataclass class AirOS8Data(AirOSDataBaseClass): diff --git a/airos/helpers.py b/airos/helpers.py index a9e1f53..bf47279 100644 --- a/airos/helpers.py +++ b/airos/helpers.py @@ -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 { diff --git a/airos/model_map.py b/airos/model_map.py index df9ff5e..a4c3405 100644 --- a/airos/model_map.py +++ b/airos/model_map.py @@ -3,6 +3,7 @@ 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", @@ -75,6 +76,7 @@ "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 } @@ -90,15 +92,39 @@ def get_sku_by_devmodel(self, devmodel: str) -> str: if devmodel in MODELS: return MODELS[devmodel] - match_key = None - matches_found = 0 + 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: - if lower_devmodel in model_name.lower(): - match_key = model_name + lower_model_name = model_name.lower() + + if lower_model_name.startswith(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}") diff --git a/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json b/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json index 8bc4002..1c4d301 100644 --- a/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json +++ b/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json @@ -17,6 +17,7 @@ "ptmp": false, "ptp": true, "role": "access_point", + "sku": "AMBIGUOUS", "station": false }, "firewall": { diff --git a/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json b/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json index e0a716e..4173c8f 100644 --- a/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json +++ b/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json @@ -17,6 +17,7 @@ "ptmp": false, "ptp": true, "role": "station", + "sku": "AMBIGUOUS", "station": true }, "firewall": { diff --git a/fixtures/airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json b/fixtures/airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json index 57baeba..afceca4 100644 --- a/fixtures/airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json +++ b/fixtures/airos_NanoBeam_5AC_ap-ptmp_v8.7.18.json @@ -17,6 +17,7 @@ "ptmp": true, "ptp": false, "role": "access_point", + "sku": "NBE-5AC-GEN2", "station": false }, "firewall": { diff --git a/fixtures/airos_NanoStation_M5_sta_v6.3.16.json b/fixtures/airos_NanoStation_M5_sta_v6.3.16.json index 94133fd..4682d57 100644 --- a/fixtures/airos_NanoStation_M5_sta_v6.3.16.json +++ b/fixtures/airos_NanoStation_M5_sta_v6.3.16.json @@ -10,6 +10,7 @@ "ptmp": false, "ptp": true, "role": "station", + "sku": "LocoM5", "station": true }, "firewall": { diff --git a/fixtures/airos_liteapgps_ap_ptmp_40mhz.json b/fixtures/airos_liteapgps_ap_ptmp_40mhz.json index f052d24..dd522e4 100644 --- a/fixtures/airos_liteapgps_ap_ptmp_40mhz.json +++ b/fixtures/airos_liteapgps_ap_ptmp_40mhz.json @@ -17,6 +17,7 @@ "ptmp": true, "ptp": false, "role": "access_point", + "sku": "UNKNOWN", "station": false }, "firewall": { diff --git a/fixtures/airos_loco5ac_ap-ptp.json b/fixtures/airos_loco5ac_ap-ptp.json index a1d075d..b4019c4 100644 --- a/fixtures/airos_loco5ac_ap-ptp.json +++ b/fixtures/airos_loco5ac_ap-ptp.json @@ -17,6 +17,7 @@ "ptmp": false, "ptp": true, "role": "access_point", + "sku": "Loco5AC", "station": false }, "firewall": { diff --git a/fixtures/airos_loco5ac_sta-ptp.json b/fixtures/airos_loco5ac_sta-ptp.json index 41f9e44..8fafa7e 100644 --- a/fixtures/airos_loco5ac_sta-ptp.json +++ b/fixtures/airos_loco5ac_sta-ptp.json @@ -17,6 +17,7 @@ "ptmp": false, "ptp": true, "role": "station", + "sku": "Loco5AC", "station": true }, "firewall": { diff --git a/fixtures/airos_nanobeam5ac_sta_ptmp_40mhz.json b/fixtures/airos_nanobeam5ac_sta_ptmp_40mhz.json index ba9014b..f0bf919 100644 --- a/fixtures/airos_nanobeam5ac_sta_ptmp_40mhz.json +++ b/fixtures/airos_nanobeam5ac_sta_ptmp_40mhz.json @@ -17,6 +17,7 @@ "ptmp": true, "ptp": false, "role": "station", + "sku": "NBE-5AC-GEN2", "station": true }, "firewall": { diff --git a/fixtures/airos_nanostation_ap-ptp_8718_missing_gps.json b/fixtures/airos_nanostation_ap-ptp_8718_missing_gps.json index 05d8b09..6e00e08 100644 --- a/fixtures/airos_nanostation_ap-ptp_8718_missing_gps.json +++ b/fixtures/airos_nanostation_ap-ptp_8718_missing_gps.json @@ -17,6 +17,7 @@ "ptmp": false, "ptp": true, "role": "access_point", + "sku": "Loco5AC", "station": false }, "firewall": { diff --git a/tests/test_stations.py b/tests/test_stations.py index 1682601..36f1943 100644 --- a/tests/test_stations.py +++ b/tests/test_stations.py @@ -141,20 +141,20 @@ async def test_status_logs_exception_on_missing_field( @pytest.mark.parametrize( - ("mode", "fixture"), + ("mode", "fixture", "sku"), [ - ("ap-ptp", "loco5ac_ap-ptp"), - ("ap-ptp", "nanostation_ap-ptp_8718_missing_gps"), - ("sta-ptp", "loco5ac_sta-ptp"), - ("sta-ptmp", "mocked_sta-ptmp"), - ("ap-ptmp", "liteapgps_ap_ptmp_40mhz"), - ("sta-ptmp", "nanobeam5ac_sta_ptmp_40mhz"), - ("ap-ptmp", "NanoBeam_5AC_ap-ptmp_v8.7.18"), + ("ap-ptp", "loco5ac_ap-ptp", "Loco5AC"), + ("ap-ptp", "nanostation_ap-ptp_8718_missing_gps", "Loco5AC"), + ("sta-ptp", "loco5ac_sta-ptp", "Loco5AC"), + ("sta-ptmp", "mocked_sta-ptmp", "Loco5AC"), + ("ap-ptmp", "liteapgps_ap_ptmp_40mhz", "UNKNOWN"), + ("sta-ptmp", "nanobeam5ac_sta_ptmp_40mhz", "NBE-5AC-GEN2"), + ("ap-ptmp", "NanoBeam_5AC_ap-ptmp_v8.7.18", "NBE-5AC-GEN2"), ], ) @pytest.mark.asyncio async def test_ap_object( - airos8_device: AirOS8, base_url: str, mode: str, fixture: str + airos8_device: AirOS8, base_url: str, mode: str, fixture: str, sku: str ) -> None: """Test device operation using the new _request_json method.""" fixture_data = await _read_fixture(fixture) @@ -180,6 +180,7 @@ async def test_ap_object( # Assertions remain the same as they check the final result assert status.wireless.mode assert status.wireless.mode.value == mode + assert status.derived.sku == sku assert status.derived.mac_interface == "br0" cookie = SimpleCookie() From b37bd40138e3dfe2452320752d155394d923e6ef Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 11 Oct 2025 23:15:54 +0200 Subject: [PATCH 3/6] Add endswith SKU lookup --- airos/model_map.py | 2 +- fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json | 2 +- fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json | 2 +- tests/test_model_map.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airos/model_map.py b/airos/model_map.py index a4c3405..7b2b596 100644 --- a/airos/model_map.py +++ b/airos/model_map.py @@ -103,7 +103,7 @@ def get_sku_by_devmodel(self, devmodel: str) -> str: for model_name in MODELS: lower_model_name = model_name.lower() - if lower_model_name.startswith(lower_devmodel): + if lower_model_name.endswith(lower_devmodel): if not best_match_is_prefix or len(lower_model_name) == len( lower_devmodel ): diff --git a/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json b/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json index 1c4d301..c507f45 100644 --- a/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json +++ b/fixtures/airos_LiteBeam5AC_ap-ptp_30mhz.json @@ -17,7 +17,7 @@ "ptmp": false, "ptp": true, "role": "access_point", - "sku": "AMBIGUOUS", + "sku": "LBE-5AC-GEN2", "station": false }, "firewall": { diff --git a/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json b/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json index 4173c8f..d6fa87f 100644 --- a/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json +++ b/fixtures/airos_LiteBeam5AC_sta-ptp_30mhz.json @@ -17,7 +17,7 @@ "ptmp": false, "ptp": true, "role": "station", - "sku": "AMBIGUOUS", + "sku": "LBE-5AC-GEN2", "station": true }, "firewall": { diff --git a/tests/test_model_map.py b/tests/test_model_map.py index 3d08936..dd1cfd5 100644 --- a/tests/test_model_map.py +++ b/tests/test_model_map.py @@ -38,10 +38,10 @@ def test_get_sku_by_devmodel_not_found_raises_keyerror(self): def test_get_sku_by_devmodel_multiple_matches_raises_exception_dynamic(self): """Test to raise AirOSMultipleMatchesFoundException when partial match is ambiguous.""" with pytest.raises(AirOSMultipleMatchesFoundException) as excinfo: - self.mapper.get_sku_by_devmodel("Rocket") + self.mapper.get_sku_by_devmodel("LiteBeam") exception_message = str(excinfo.value) - expected_matches = 7 + expected_matches = 4 match = re.search(r"matched multiple \((\d+)\) products", exception_message) assert match is not None From e1d729460eb2edd440b17b4d3fa486b92eec4cb2 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 11 Oct 2025 23:20:25 +0200 Subject: [PATCH 4/6] Add LiteAP GPS shorthand --- airos/model_map.py | 1 + fixtures/airos_liteapgps_ap_ptmp_40mhz.json | 2 +- fixtures/userdata/mocked_sta-ptmp.json | 2 +- tests/test_stations.py | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/airos/model_map.py b/airos/model_map.py index 7b2b596..8f69b77 100644 --- a/airos/model_map.py +++ b/airos/model_map.py @@ -77,6 +77,7 @@ "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 } diff --git a/fixtures/airos_liteapgps_ap_ptmp_40mhz.json b/fixtures/airos_liteapgps_ap_ptmp_40mhz.json index dd522e4..d0d827a 100644 --- a/fixtures/airos_liteapgps_ap_ptmp_40mhz.json +++ b/fixtures/airos_liteapgps_ap_ptmp_40mhz.json @@ -17,7 +17,7 @@ "ptmp": true, "ptp": false, "role": "access_point", - "sku": "UNKNOWN", + "sku": "LAP-GPS", "station": false }, "firewall": { diff --git a/fixtures/userdata/mocked_sta-ptmp.json b/fixtures/userdata/mocked_sta-ptmp.json index 4813370..5670c02 100644 --- a/fixtures/userdata/mocked_sta-ptmp.json +++ b/fixtures/userdata/mocked_sta-ptmp.json @@ -1 +1 @@ -{"chain_names":[{"number":1,"name": "Chain 0"},{"number":2,"name": "Chain 1"}],"host":{"hostname": "NanoStation 5AC sta name WITH TWO REMOTES","device_id": "d4f4cdf82961e619328a8f72f8d7653b","uptime":265375,"power_time":268567,"time": "2025-06-23 23:14:49","timestamp":2668800167,"fwversion": "v8.7.17","devmodel": "NanoStation 5AC loco","netrole": "bridge","loadavg":0.359863,"totalram":63447040,"freeram":16105472,"temperature":0,"cpuload":44.000000,"height":2},"genuine": "/images/genuine.png","services":{"dhcpc":false,"dhcpd":false,"dhcp6d_stateful":false,"pppoe":false,"airview":2},"firewall":{"iptables":false,"ebtables":false,"ip6tables":false,"eb6tables":false},"portfw":false,"wireless":{"essid": "DemoSSID","mode": "sta-ptmp","ieeemode": "AUTO","band":2,"compat_11n":0,"hide_essid":0,"apmac":"01:23:45:67:89:AB","antenna_gain":13,"frequency":5500,"center1_freq":5530,"dfs":1,"distance":0,"security": "WPA2","noisef":-89,"txpower":-4,"aprepeater":false,"rstatus":5,"chanbw":80,"rx_chainmask":3,"tx_chainmask":3,"nol_state":0,"nol_timeout":0,"cac_state":0,"cac_timeout":0,"rx_idx":9,"rx_nss":2,"tx_idx":8,"tx_nss":2,"throughput":{"tx":12014,"rx":267},"service":{"time":267250,"link":266051},"polling":{"cb_capacity":586950,"dl_capacity":647400,"ul_capacity":526500,"use":46,"tx_use":40,"rx_use":6,"atpc_status":2,"fixed_frame":false,"gps_sync":false,"flex_mode":1,"ff_cap_rep":false},"count":1,"sta":[{"mac":"01:23:45:67:89:GH","lastip":"192.168.1.3","signal":-58,"rssi":38,"noisefloor":-89,"chainrssi":[33,37,0],"tx_idx":8,"rx_idx":9,"tx_nss":2,"rx_nss":2,"tx_latency":0,"distance":1,"tx_packets":0,"tx_lretries":0,"tx_sretries":0,"uptime":170335,"dl_signal_expect":-45,"ul_signal_expect":-59,"cb_capacity_expect":658000,"dl_capacity_expect":692000,"ul_capacity_expect":624000,"dl_rate_expect":9,"ul_rate_expect":8,"dl_linkscore":93,"ul_linkscore":84,"dl_avg_linkscore":93,"ul_avg_linkscore":87,"tx_ratedata":[14,4,372,2223,4708,4037,8142,486840,29437014,24752069],"stats":{"rx_bytes":3622839202,"rx_packets":52999540,"rx_pps":446,"tx_bytes":212303079651,"tx_packets":149832292,"tx_pps":0},"airmax":{"actual_priority":0,"beam":0,"desired_priority":0,"cb_capacity":586950,"dl_capacity":647400,"ul_capacity":526500,"atpc_status":2,"rx":{"usage":6,"cinr":31,"evm":[[30,32,30,29,33,31,29,33,31,31,30,33,34,33,31,33,32,32,31,29,31,30,32,31,30,29,32,31,32,31,31,32,29,31,29,30,32,32,31,32,32,33,31,28,29,31,31,33,32,33,32,32,32,31,33,29,27,31,28,29,31,31,34,28],[39,39,39,39,39,41,39,39,39,39,39,39,38,39,39,39,39,39,39,39,40,39,39,40,39,39,39,40,39,40,39,39,39,39,39,38,39,39,39,39,39,39,40,39,39,40,39,38,39,39,39,39,39,39,39,39,39,39,39,39,38,39,39,39]]},"tx":{"usage":40,"cinr":31,"evm":[[32,31,30,26,32,32,31,28,33,32,32,32,31,31,31,29,30,32,30,27,34,31,31,30,32,29,31,29,31,33,31,31,32,30,31,34,33,31,30,31,30,31,31,32,31,30,33,31,30,31,27,31,30,30,30,30,30,29,32,34,31,30,28,30],[35,35,37,36,36,35,36,36,37,36,37,37,36,36,36,36,36,36,36,36,37,37,36,36,37,36,35,35,37,36,36,37,36,37,36,36,37,36,36,35,36,36,36,36,36,37,37,37,36,37,35,36,36,36,36,37,37,36,36,36,36,36,36,36]]}},"last_disc":1,"remote":{"age":2,"device_id": "03aa0d0b40fed0a47088293584ef4418","hostname": "NanoStation 5AC ap name WITHOUT MODE","platform": "NanoStation 5AC loco","version": "WA.ar934x.v8.7.17.48152.250620.2132","time": "2025-06-23 23:07:34","cpuload":5.050500,"temperature":0,"totalram":63447040,"freeram":16633856,"netrole": "bridge","sys_id":"0xe7fa","tx_throughput":314,"rx_throughput":10548,"uptime":264941,"power_time":268736,"compat_11n":0,"signal":-60,"rssi":36,"noisefloor":-89,"tx_power":-3,"distance":1,"rx_chainmask":3,"chainrssi":[35,31,0],"tx_ratedata":[175,4,47,200,673,158,163,138,68926,19583506],"tx_bytes":5267487876,"rx_bytes":207021597130,"antenna_gain":13,"cable_loss":0,"height":3,"ethlist":[{"ifname": "eth0","enabled":true,"plugged":true,"duplex":true,"speed":1000,"snr":[30,30,30,30],"cable_len":18}],"ipaddr":["192.168.1.3"],"ip6addr":["fe80::eea:14ff:fea4:89cd"],"gps":{"lat": "52.379894","lon": "4.901608","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267234,"link":266056}}},{"mac":"01:23:45:67:89:EF","lastip":"192.168.1.3","signal":-58,"rssi":38,"noisefloor":-89,"chainrssi":[33,37,0],"tx_idx":8,"rx_idx":9,"tx_nss":2,"rx_nss":2,"tx_latency":0,"distance":1,"tx_packets":0,"tx_lretries":0,"tx_sretries":0,"uptime":170335,"dl_signal_expect":-45,"ul_signal_expect":-59,"cb_capacity_expect":658000,"dl_capacity_expect":692000,"ul_capacity_expect":624000,"dl_rate_expect":9,"ul_rate_expect":8,"dl_linkscore":93,"ul_linkscore":84,"dl_avg_linkscore":93,"ul_avg_linkscore":87,"tx_ratedata":[14,4,372,2223,4708,4037,8142,486840,29437014,24752069],"stats":{"rx_bytes":3622839202,"rx_packets":52999540,"rx_pps":446,"tx_bytes":212303079651,"tx_packets":149832292,"tx_pps":0},"airmax":{"actual_priority":0,"beam":0,"desired_priority":0,"cb_capacity":586950,"dl_capacity":647400,"ul_capacity":526500,"atpc_status":2,"rx":{"usage":6,"cinr":31,"evm":[[30,32,30,29,33,31,29,33,31,31,30,33,34,33,31,33,32,32,31,29,31,30,32,31,30,29,32,31,32,31,31,32,29,31,29,30,32,32,31,32,32,33,31,28,29,31,31,33,32,33,32,32,32,31,33,29,27,31,28,29,31,31,34,28],[39,39,39,39,39,41,39,39,39,39,39,39,38,39,39,39,39,39,39,39,40,39,39,40,39,39,39,40,39,40,39,39,39,39,39,38,39,39,39,39,39,39,40,39,39,40,39,38,39,39,39,39,39,39,39,39,39,39,39,39,38,39,39,39]]},"tx":{"usage":40,"cinr":31,"evm":[[32,31,30,26,32,32,31,28,33,32,32,32,31,31,31,29,30,32,30,27,34,31,31,30,32,29,31,29,31,33,31,31,32,30,31,34,33,31,30,31,30,31,31,32,31,30,33,31,30,31,27,31,30,30,30,30,30,29,32,34,31,30,28,30],[35,35,37,36,36,35,36,36,37,36,37,37,36,36,36,36,36,36,36,36,37,37,36,36,37,36,35,35,37,36,36,37,36,37,36,36,37,36,36,35,36,36,36,36,36,37,37,37,36,37,35,36,36,36,36,37,37,36,36,36,36,36,36,36]]}},"last_disc":1,"remote":{"age":2,"device_id": "03aa0d0b40fed0a47088293584ef4418","hostname": "NanoStation 5AC ap name WITH MODE","platform": "NanoStation 5AC loco","version": "WA.ar934x.v8.7.17.48152.250620.2132","time": "2025-06-23 23:07:34","cpuload":5.050500,"temperature":0,"totalram":63447040,"freeram":16633856,"netrole": "bridge","mode": "ap-ptmp","sys_id":"0xe7fa","tx_throughput":314,"rx_throughput":10548,"uptime":264941,"power_time":268736,"compat_11n":0,"signal":-60,"rssi":36,"noisefloor":-89,"tx_power":-3,"distance":1,"rx_chainmask":3,"chainrssi":[35,31,0],"tx_ratedata":[175,4,47,200,673,158,163,138,68926,19583506],"tx_bytes":5267487876,"rx_bytes":207021597130,"antenna_gain":13,"cable_loss":0,"height":3,"ethlist":[{"ifname": "eth0","enabled":true,"plugged":true,"duplex":true,"speed":1000,"snr":[30,30,30,30],"cable_len":18}],"ipaddr":["192.168.1.3"],"ip6addr":["fe80::eea:14ff:fea4:89cd"],"gps":{"lat": "52.379894","lon": "4.901608","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267234,"link":266056}}}],"sta_disconnected":[]},"interfaces":[{"ifname": "eth0","hwaddr":"01:23:45:67:89:CD","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":4975926283,"rx_bytes":206979884583,"tx_packets":73329864,"rx_packets":185401454,"tx_errors":0,"rx_errors":0,"tx_dropped":0,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":1000,"duplex":true,"snr":[30,30,30,30],"cable_len":14}},{"ifname": "ath0","hwaddr":"01:23:45:67:89:CD","enabled":true,"mtu":1500,"status":{"plugged":false,"tx_bytes":212398179151,"rx_bytes":3625607638,"tx_packets":149906916,"rx_packets":53038237,"tx_errors":0,"rx_errors":0,"tx_dropped":34,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":0,"duplex":false}},{"ifname": "br0","hwaddr":"01:23:45:67:89:CD","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":143856488,"rx_bytes":198175800,"tx_packets":204749,"rx_packets":1753443,"tx_errors":0,"rx_errors":0,"tx_dropped":0,"rx_dropped":0,"ipaddr":"192.168.1.3","ip6addr":[{"addr":"fe80::eea:14ff:fea4:89ab","plen":64}],"speed":0,"duplex":false}}],"provmode":{},"ntpclient":{},"unms":{"status":0},"gps":{"lat":52.379894,"lon":4.901608,"fix":0,"sats":9,"dim":3,"dop":"0.91","alt":"248.6","time_synced":0}} +{"chain_names":[{"number":1,"name": "Chain 0"},{"number":2,"name": "Chain 1"}],"host":{"hostname": "NanoStation 5AC sta name WITH TWO REMOTES","device_id": "d4f4cdf82961e619328a8f72f8d7653b","uptime":265375,"power_time":268567,"time": "2025-06-23 23:14:49","timestamp":2668800167,"fwversion": "v8.7.17","devmodel": "NanoStation 5AC loco unexisting","netrole": "bridge","loadavg":0.359863,"totalram":63447040,"freeram":16105472,"temperature":0,"cpuload":44.000000,"height":2},"genuine": "/images/genuine.png","services":{"dhcpc":false,"dhcpd":false,"dhcp6d_stateful":false,"pppoe":false,"airview":2},"firewall":{"iptables":false,"ebtables":false,"ip6tables":false,"eb6tables":false},"portfw":false,"wireless":{"essid": "DemoSSID","mode": "sta-ptmp","ieeemode": "AUTO","band":2,"compat_11n":0,"hide_essid":0,"apmac":"01:23:45:67:89:AB","antenna_gain":13,"frequency":5500,"center1_freq":5530,"dfs":1,"distance":0,"security": "WPA2","noisef":-89,"txpower":-4,"aprepeater":false,"rstatus":5,"chanbw":80,"rx_chainmask":3,"tx_chainmask":3,"nol_state":0,"nol_timeout":0,"cac_state":0,"cac_timeout":0,"rx_idx":9,"rx_nss":2,"tx_idx":8,"tx_nss":2,"throughput":{"tx":12014,"rx":267},"service":{"time":267250,"link":266051},"polling":{"cb_capacity":586950,"dl_capacity":647400,"ul_capacity":526500,"use":46,"tx_use":40,"rx_use":6,"atpc_status":2,"fixed_frame":false,"gps_sync":false,"flex_mode":1,"ff_cap_rep":false},"count":1,"sta":[{"mac":"01:23:45:67:89:GH","lastip":"192.168.1.3","signal":-58,"rssi":38,"noisefloor":-89,"chainrssi":[33,37,0],"tx_idx":8,"rx_idx":9,"tx_nss":2,"rx_nss":2,"tx_latency":0,"distance":1,"tx_packets":0,"tx_lretries":0,"tx_sretries":0,"uptime":170335,"dl_signal_expect":-45,"ul_signal_expect":-59,"cb_capacity_expect":658000,"dl_capacity_expect":692000,"ul_capacity_expect":624000,"dl_rate_expect":9,"ul_rate_expect":8,"dl_linkscore":93,"ul_linkscore":84,"dl_avg_linkscore":93,"ul_avg_linkscore":87,"tx_ratedata":[14,4,372,2223,4708,4037,8142,486840,29437014,24752069],"stats":{"rx_bytes":3622839202,"rx_packets":52999540,"rx_pps":446,"tx_bytes":212303079651,"tx_packets":149832292,"tx_pps":0},"airmax":{"actual_priority":0,"beam":0,"desired_priority":0,"cb_capacity":586950,"dl_capacity":647400,"ul_capacity":526500,"atpc_status":2,"rx":{"usage":6,"cinr":31,"evm":[[30,32,30,29,33,31,29,33,31,31,30,33,34,33,31,33,32,32,31,29,31,30,32,31,30,29,32,31,32,31,31,32,29,31,29,30,32,32,31,32,32,33,31,28,29,31,31,33,32,33,32,32,32,31,33,29,27,31,28,29,31,31,34,28],[39,39,39,39,39,41,39,39,39,39,39,39,38,39,39,39,39,39,39,39,40,39,39,40,39,39,39,40,39,40,39,39,39,39,39,38,39,39,39,39,39,39,40,39,39,40,39,38,39,39,39,39,39,39,39,39,39,39,39,39,38,39,39,39]]},"tx":{"usage":40,"cinr":31,"evm":[[32,31,30,26,32,32,31,28,33,32,32,32,31,31,31,29,30,32,30,27,34,31,31,30,32,29,31,29,31,33,31,31,32,30,31,34,33,31,30,31,30,31,31,32,31,30,33,31,30,31,27,31,30,30,30,30,30,29,32,34,31,30,28,30],[35,35,37,36,36,35,36,36,37,36,37,37,36,36,36,36,36,36,36,36,37,37,36,36,37,36,35,35,37,36,36,37,36,37,36,36,37,36,36,35,36,36,36,36,36,37,37,37,36,37,35,36,36,36,36,37,37,36,36,36,36,36,36,36]]}},"last_disc":1,"remote":{"age":2,"device_id": "03aa0d0b40fed0a47088293584ef4418","hostname": "NanoStation 5AC ap name WITHOUT MODE","platform": "NanoStation 5AC loco unexisting","version": "WA.ar934x.v8.7.17.48152.250620.2132","time": "2025-06-23 23:07:34","cpuload":5.050500,"temperature":0,"totalram":63447040,"freeram":16633856,"netrole": "bridge","sys_id":"0xe7fa","tx_throughput":314,"rx_throughput":10548,"uptime":264941,"power_time":268736,"compat_11n":0,"signal":-60,"rssi":36,"noisefloor":-89,"tx_power":-3,"distance":1,"rx_chainmask":3,"chainrssi":[35,31,0],"tx_ratedata":[175,4,47,200,673,158,163,138,68926,19583506],"tx_bytes":5267487876,"rx_bytes":207021597130,"antenna_gain":13,"cable_loss":0,"height":3,"ethlist":[{"ifname": "eth0","enabled":true,"plugged":true,"duplex":true,"speed":1000,"snr":[30,30,30,30],"cable_len":18}],"ipaddr":["192.168.1.3"],"ip6addr":["fe80::eea:14ff:fea4:89cd"],"gps":{"lat": "52.379894","lon": "4.901608","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267234,"link":266056}}},{"mac":"01:23:45:67:89:EF","lastip":"192.168.1.3","signal":-58,"rssi":38,"noisefloor":-89,"chainrssi":[33,37,0],"tx_idx":8,"rx_idx":9,"tx_nss":2,"rx_nss":2,"tx_latency":0,"distance":1,"tx_packets":0,"tx_lretries":0,"tx_sretries":0,"uptime":170335,"dl_signal_expect":-45,"ul_signal_expect":-59,"cb_capacity_expect":658000,"dl_capacity_expect":692000,"ul_capacity_expect":624000,"dl_rate_expect":9,"ul_rate_expect":8,"dl_linkscore":93,"ul_linkscore":84,"dl_avg_linkscore":93,"ul_avg_linkscore":87,"tx_ratedata":[14,4,372,2223,4708,4037,8142,486840,29437014,24752069],"stats":{"rx_bytes":3622839202,"rx_packets":52999540,"rx_pps":446,"tx_bytes":212303079651,"tx_packets":149832292,"tx_pps":0},"airmax":{"actual_priority":0,"beam":0,"desired_priority":0,"cb_capacity":586950,"dl_capacity":647400,"ul_capacity":526500,"atpc_status":2,"rx":{"usage":6,"cinr":31,"evm":[[30,32,30,29,33,31,29,33,31,31,30,33,34,33,31,33,32,32,31,29,31,30,32,31,30,29,32,31,32,31,31,32,29,31,29,30,32,32,31,32,32,33,31,28,29,31,31,33,32,33,32,32,32,31,33,29,27,31,28,29,31,31,34,28],[39,39,39,39,39,41,39,39,39,39,39,39,38,39,39,39,39,39,39,39,40,39,39,40,39,39,39,40,39,40,39,39,39,39,39,38,39,39,39,39,39,39,40,39,39,40,39,38,39,39,39,39,39,39,39,39,39,39,39,39,38,39,39,39]]},"tx":{"usage":40,"cinr":31,"evm":[[32,31,30,26,32,32,31,28,33,32,32,32,31,31,31,29,30,32,30,27,34,31,31,30,32,29,31,29,31,33,31,31,32,30,31,34,33,31,30,31,30,31,31,32,31,30,33,31,30,31,27,31,30,30,30,30,30,29,32,34,31,30,28,30],[35,35,37,36,36,35,36,36,37,36,37,37,36,36,36,36,36,36,36,36,37,37,36,36,37,36,35,35,37,36,36,37,36,37,36,36,37,36,36,35,36,36,36,36,36,37,37,37,36,37,35,36,36,36,36,37,37,36,36,36,36,36,36,36]]}},"last_disc":1,"remote":{"age":2,"device_id": "03aa0d0b40fed0a47088293584ef4418","hostname": "NanoStation 5AC ap name WITH MODE","platform": "NanoStation 5AC loco unexisting","version": "WA.ar934x.v8.7.17.48152.250620.2132","time": "2025-06-23 23:07:34","cpuload":5.050500,"temperature":0,"totalram":63447040,"freeram":16633856,"netrole": "bridge","mode": "ap-ptmp","sys_id":"0xe7fa","tx_throughput":314,"rx_throughput":10548,"uptime":264941,"power_time":268736,"compat_11n":0,"signal":-60,"rssi":36,"noisefloor":-89,"tx_power":-3,"distance":1,"rx_chainmask":3,"chainrssi":[35,31,0],"tx_ratedata":[175,4,47,200,673,158,163,138,68926,19583506],"tx_bytes":5267487876,"rx_bytes":207021597130,"antenna_gain":13,"cable_loss":0,"height":3,"ethlist":[{"ifname": "eth0","enabled":true,"plugged":true,"duplex":true,"speed":1000,"snr":[30,30,30,30],"cable_len":18}],"ipaddr":["192.168.1.3"],"ip6addr":["fe80::eea:14ff:fea4:89cd"],"gps":{"lat": "52.379894","lon": "4.901608","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267234,"link":266056}}}],"sta_disconnected":[]},"interfaces":[{"ifname": "eth0","hwaddr":"01:23:45:67:89:CD","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":4975926283,"rx_bytes":206979884583,"tx_packets":73329864,"rx_packets":185401454,"tx_errors":0,"rx_errors":0,"tx_dropped":0,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":1000,"duplex":true,"snr":[30,30,30,30],"cable_len":14}},{"ifname": "ath0","hwaddr":"01:23:45:67:89:CD","enabled":true,"mtu":1500,"status":{"plugged":false,"tx_bytes":212398179151,"rx_bytes":3625607638,"tx_packets":149906916,"rx_packets":53038237,"tx_errors":0,"rx_errors":0,"tx_dropped":34,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":0,"duplex":false}},{"ifname": "br0","hwaddr":"01:23:45:67:89:CD","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":143856488,"rx_bytes":198175800,"tx_packets":204749,"rx_packets":1753443,"tx_errors":0,"rx_errors":0,"tx_dropped":0,"rx_dropped":0,"ipaddr":"192.168.1.3","ip6addr":[{"addr":"fe80::eea:14ff:fea4:89ab","plen":64}],"speed":0,"duplex":false}}],"provmode":{},"ntpclient":{},"unms":{"status":0},"gps":{"lat":52.379894,"lon":4.901608,"fix":0,"sats":9,"dim":3,"dop":"0.91","alt":"248.6","time_synced":0}} diff --git a/tests/test_stations.py b/tests/test_stations.py index 36f1943..9ef53f5 100644 --- a/tests/test_stations.py +++ b/tests/test_stations.py @@ -146,8 +146,8 @@ async def test_status_logs_exception_on_missing_field( ("ap-ptp", "loco5ac_ap-ptp", "Loco5AC"), ("ap-ptp", "nanostation_ap-ptp_8718_missing_gps", "Loco5AC"), ("sta-ptp", "loco5ac_sta-ptp", "Loco5AC"), - ("sta-ptmp", "mocked_sta-ptmp", "Loco5AC"), - ("ap-ptmp", "liteapgps_ap_ptmp_40mhz", "UNKNOWN"), + ("sta-ptmp", "mocked_sta-ptmp", "UNKNOWN"), + ("ap-ptmp", "liteapgps_ap_ptmp_40mhz", "LAP-GPS"), ("sta-ptmp", "nanobeam5ac_sta_ptmp_40mhz", "NBE-5AC-GEN2"), ("ap-ptmp", "NanoBeam_5AC_ap-ptmp_v8.7.18", "NBE-5AC-GEN2"), ], From dfd54696fc3091ffc9b928ac44a119294ecea262 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 11 Oct 2025 23:27:55 +0200 Subject: [PATCH 5/6] Ensure fixtures --- fixtures/airos_mocked_sta-ptmp.json | 16 ++++++++++++---- script/generate_ha_fixture.py | 2 +- script/mashumaro-step-debug.py | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/fixtures/airos_mocked_sta-ptmp.json b/fixtures/airos_mocked_sta-ptmp.json index 6d3b25e..524dad8 100644 --- a/fixtures/airos_mocked_sta-ptmp.json +++ b/fixtures/airos_mocked_sta-ptmp.json @@ -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": { @@ -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, @@ -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, @@ -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, diff --git a/script/generate_ha_fixture.py b/script/generate_ha_fixture.py index 7e2f32a..d8b8dc1 100644 --- a/script/generate_ha_fixture.py +++ b/script/generate_ha_fixture.py @@ -34,7 +34,7 @@ def generate_airos_fixtures() -> None: # Iterate over all files in the userdata_dir for filename in os.listdir(userdata_dir): # noqa: PTH208 - if "mocked" in filename: + if filename != "mocked_sta-ptmp.json" and "mocked" in filename: continue if filename.endswith(".json"): # Construct the full paths for the base and new fixtures diff --git a/script/mashumaro-step-debug.py b/script/mashumaro-step-debug.py index 59d6b1a..540cc8b 100644 --- a/script/mashumaro-step-debug.py +++ b/script/mashumaro-step-debug.py @@ -128,8 +128,9 @@ def main() -> None: data, AirOS8.derived_wireless_data ) _LOGGER.info("Attempting to deserialize full AirOS8Data object...") - airos_data_obj = AirOS8Data.from_dict(derived_data) # noqa: F841 + airos_data_obj = AirOS8Data.from_dict(derived_data) _LOGGER.info("Success! Full AirOS8Data object is valid.") + _LOGGER.info("SKU-check: %s", airos_data_obj.derived.sku) except Exception: _LOGGER.info("\n------------------") From 82d1d8c49eaeec7cd95e6bfb922049fdfeea3b8f Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 11 Oct 2025 23:50:17 +0200 Subject: [PATCH 6/6] Fix regression and add logging --- airos/base.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/airos/base.py b/airos/base.py index f72d033..bb76c1e 100644 --- a/airos/base.py +++ b/airos/base.py @@ -128,9 +128,19 @@ def _derived_data_helper( 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 matches found for model '%s': %s", devmodel, err) + _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] = { @@ -193,7 +203,7 @@ def _get_authenticated_headers( if self._csrf_id: # pragma: no cover headers["X-CSRF-ID"] = self._csrf_id - if self._csrf_id: # pragma: no cover + if self._auth_cookie: # pragma: no cover headers["Cookie"] = f"AIROS_{self._auth_cookie}" return headers