From 897eaf4ad62408ca88aa4f8fb4efffc7e2cdc763 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 8 Aug 2025 21:25:24 +0200 Subject: [PATCH 1/3] Fix age field in Remote for nanostation 8.7.11 --- airos/data.py | 74 ++++++++++++++------------ pyproject.toml | 2 +- script/generate_ha_fixture.py | 97 ++++++++++++++++++----------------- 3 files changed, 90 insertions(+), 83 deletions(-) diff --git a/airos/data.py b/airos/data.py index a1d850f..80d66a3 100644 --- a/airos/data.py +++ b/airos/data.py @@ -92,6 +92,12 @@ def _redact(d: dict): # Data class start +class AirOSDataClass(DataClassDictMixin): + """A base class for all mashumaro dataclasses.""" + + pass + + def _check_and_log_unknown_enum_value( data_dict: dict[str, Any], key: str, @@ -149,7 +155,7 @@ class NetRole(Enum): @dataclass -class ChainName: +class ChainName(AirOSDataClass): """Leaf definition.""" number: int @@ -157,7 +163,7 @@ class ChainName: @dataclass -class Host: +class Host(AirOSDataClass): """Leaf definition.""" hostname: str @@ -169,11 +175,11 @@ class Host: fwversion: str devmodel: str netrole: NetRole - loadavg: float + loadavg: float | int | None totalram: int freeram: int temperature: int - cpuload: float + cpuload: float | int | None height: int | None # Reported none on LiteBeam 5AC @classmethod @@ -184,7 +190,7 @@ def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]: @dataclass -class Services: +class Services(AirOSDataClass): """Leaf definition.""" dhcpc: bool @@ -195,7 +201,7 @@ class Services: @dataclass -class Firewall: +class Firewall(AirOSDataClass): """Leaf definition.""" iptables: bool @@ -205,7 +211,7 @@ class Firewall: @dataclass -class Throughput: +class Throughput(AirOSDataClass): """Leaf definition.""" tx: int @@ -213,7 +219,7 @@ class Throughput: @dataclass -class ServiceTime: +class ServiceTime(AirOSDataClass): """Leaf definition.""" time: int @@ -221,7 +227,7 @@ class ServiceTime: @dataclass -class Polling: +class Polling(AirOSDataClass): """Leaf definition.""" cb_capacity: int @@ -238,7 +244,7 @@ class Polling: @dataclass -class Stats: +class Stats(AirOSDataClass): """Leaf definition.""" rx_bytes: int @@ -250,7 +256,7 @@ class Stats: @dataclass -class EvmData: +class EvmData(AirOSDataClass): """Leaf definition.""" usage: int @@ -259,7 +265,7 @@ class EvmData: @dataclass -class Airmax: +class Airmax(AirOSDataClass): """Leaf definition.""" actual_priority: int @@ -274,7 +280,7 @@ class Airmax: @dataclass -class EthList: +class EthList(AirOSDataClass): """Leaf definition.""" ifname: str @@ -287,21 +293,21 @@ class EthList: @dataclass -class GPSData: +class GPSData(AirOSDataClass): """Leaf definition.""" - lat: float | None = None - lon: float | None = None + lat: float | int | None = None + lon: float | int | None = None fix: int | None = None sats: int | None = None # LiteAP GPS dim: int | None = None # LiteAP GPS - dop: float | None = None # LiteAP GPS - alt: float | None = None # LiteAP GPS + dop: float | int | None = None # LiteAP GPS + alt: float | int | None = None # LiteAP GPS time_synced: int | None = None # LiteAP GPS @dataclass -class UnmsStatus: +class UnmsStatus(AirOSDataClass): """Leaf definition.""" status: int @@ -309,16 +315,15 @@ class UnmsStatus: @dataclass -class Remote: +class Remote(AirOSDataClass): """Leaf definition.""" - age: int device_id: str hostname: str platform: str version: str time: str - cpuload: float + cpuload: float | int | None temperature: int totalram: int freeram: int @@ -351,6 +356,7 @@ class Remote: mode: WirelessMode | None = None # Investigate why remotes can have no mode set ip6addr: list[str] | None = None # For v4 only devices height: int | None = None + age: int | None = None # At least not present on 8.7.11 @classmethod def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]: @@ -360,7 +366,7 @@ def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]: @dataclass -class Disconnected: +class Disconnected(AirOSDataClass): """Leaf definition for disconnected devices.""" mac: str @@ -374,7 +380,7 @@ class Disconnected: @dataclass -class Station: +class Station(AirOSDataClass): """Leaf definition for connected/active devices.""" mac: str @@ -413,7 +419,7 @@ class Station: @dataclass -class Wireless: +class Wireless(AirOSDataClass): """Leaf definition.""" essid: str @@ -465,7 +471,7 @@ def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]: @dataclass -class InterfaceStatus: +class InterfaceStatus(AirOSDataClass): """Leaf definition.""" plugged: bool @@ -486,7 +492,7 @@ class InterfaceStatus: @dataclass -class Interface: +class Interface(AirOSDataClass): """Leaf definition.""" ifname: str @@ -497,30 +503,30 @@ class Interface: @dataclass -class ProvisioningMode: +class ProvisioningMode(AirOSDataClass): """Leaf definition.""" pass @dataclass -class NtpClient: +class NtpClient(AirOSDataClass): """Leaf definition.""" pass @dataclass -class GPSMain: +class GPSMain(AirOSDataClass): """Leaf definition.""" - lat: float - lon: float + lat: float | int | None + lon: float | int | None fix: int @dataclass -class Derived: +class Derived(AirOSDataClass): """Contain custom data generated by this module.""" mac: str # Base device MAC address (i.e. eth0) @@ -536,7 +542,7 @@ class Derived: @dataclass -class AirOS8Data(DataClassDictMixin): +class AirOS8Data(AirOSDataClass): """Dataclass for AirOS v8 devices.""" chain_names: list[ChainName] diff --git a/pyproject.toml b/pyproject.toml index c5965cf..0912d08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.2.6" +version = "0.2.7a0" license = "MIT" description = "Ubiquity airOS module(s) for Python 3." readme = "README.md" diff --git a/script/generate_ha_fixture.py b/script/generate_ha_fixture.py index 4774d1b..d553c1f 100644 --- a/script/generate_ha_fixture.py +++ b/script/generate_ha_fixture.py @@ -1,4 +1,4 @@ -"""Generate mock airos fixture for testing.""" +"""Generate mock airos fixtures for testing.""" import json import logging @@ -13,52 +13,53 @@ if project_root_dir not in sys.path: sys.path.append(project_root_dir) +# NOTE: This assumes the airos module is correctly installed or available in the project path. +# If not, you might need to adjust the import statement. from airos.airos8 import AirOS, AirOSData # noqa: E402 -# Define the path to save the fixture -fixture_dir = os.path.join(os.path.dirname(__file__), "../fixtures") -userdata_dir = os.path.join(os.path.dirname(__file__), "../fixtures/userdata") -new_fixture_path = os.path.join(fixture_dir, "airos_loco5ac_ap-ptp.json") -base_fixture_path = os.path.join(userdata_dir, "loco5ac_ap-ptp.json") - -with open(base_fixture_path) as source, open(new_fixture_path, "w") as new: - source_data = json.loads(source.read()) - derived_data = AirOS.derived_data(None, source_data) - new_data = AirOSData.from_dict(derived_data) - json.dump(new_data.to_dict(), new, indent=2, sort_keys=True) - -new_fixture_path = os.path.join(fixture_dir, "airos_loco5ac_sta-ptp.json") -base_fixture_path = os.path.join(userdata_dir, "loco5ac_sta-ptp.json") - -with open(base_fixture_path) as source, open(new_fixture_path, "w") as new: - source_data = json.loads(source.read()) - derived_data = AirOS.derived_data(None, source_data) - new_data = AirOSData.from_dict(derived_data) - json.dump(new_data.to_dict(), new, indent=2, sort_keys=True) - -new_fixture_path = os.path.join(fixture_dir, "airos_mocked_sta-ptmp.json") -base_fixture_path = os.path.join(userdata_dir, "mocked_sta-ptmp.json") - -with open(base_fixture_path) as source, open(new_fixture_path, "w") as new: - source_data = json.loads(source.read()) - derived_data = AirOS.derived_data(None, source_data) - new_data = AirOSData.from_dict(derived_data) - json.dump(new_data.to_dict(), new, indent=2, sort_keys=True) - -new_fixture_path = os.path.join(fixture_dir, "airos_liteapgps_ap_ptmp_40mhz.json") -base_fixture_path = os.path.join(userdata_dir, "liteapgps_ap_ptmp_40mhz.json") - -with open(base_fixture_path) as source, open(new_fixture_path, "w") as new: - source_data = json.loads(source.read()) - derived_data = AirOS.derived_data(None, source_data) - new_data = AirOSData.from_dict(derived_data) - json.dump(new_data.to_dict(), new, indent=2, sort_keys=True) - -new_fixture_path = os.path.join(fixture_dir, "airos_nanobeam5ac_sta_ptmp_40mhz.json") -base_fixture_path = os.path.join(userdata_dir, "nanobeam5ac_sta_ptmp_40mhz.json") - -with open(base_fixture_path) as source, open(new_fixture_path, "w") as new: - source_data = json.loads(source.read()) - derived_data = AirOS.derived_data(None, source_data) - new_data = AirOSData.from_dict(derived_data) - json.dump(new_data.to_dict(), new, indent=2, sort_keys=True) + +def generate_airos_fixtures(): + """Process all (intended) JSON files from the userdata directory to potential fixtures.""" + + # Define the paths to the directories + fixture_dir = os.path.join(os.path.dirname(__file__), "../fixtures") + userdata_dir = os.path.join(os.path.dirname(__file__), "../fixtures/userdata") + + # Ensure the fixture directory exists + os.makedirs(fixture_dir, exist_ok=True) + + # Iterate over all files in the userdata_dir + for filename in os.listdir(userdata_dir): + if "mocked" in filename: + continue + if filename.endswith(".json"): + # Construct the full paths for the base and new fixtures + base_fixture_path = os.path.join(userdata_dir, filename) + new_filename = f"airos_{filename}" + new_fixture_path = os.path.join(fixture_dir, new_filename) + + _LOGGER.info("Processing '%s'...", filename) + + try: + with open(base_fixture_path) as source: + source_data = json.loads(source.read()) + + derived_data = AirOS.derived_data(None, source_data) + new_data = AirOSData.from_dict(derived_data) + + with open(new_fixture_path, "w") as new: + json.dump(new_data.to_dict(), new, indent=2, sort_keys=True) + + _LOGGER.info("Successfully created '%s'", new_filename) + + except json.JSONDecodeError: + _LOGGER.error("Skipping '%s': Not a valid JSON file.", filename) + except Exception as e: + _LOGGER.error("Error processing '%s': %s", filename, e) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" + ) + generate_airos_fixtures() From 7f973422ba2bc86a5a2f30b9f4b8f048c6e8387b Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 8 Aug 2025 21:32:39 +0200 Subject: [PATCH 2/3] Debug script --- script/mashumaro-step-debug.py | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 script/mashumaro-step-debug.py diff --git a/script/mashumaro-step-debug.py b/script/mashumaro-step-debug.py new file mode 100644 index 0000000..dce5f2d --- /dev/null +++ b/script/mashumaro-step-debug.py @@ -0,0 +1,78 @@ +"""Debug userdata json to see where things don't add up.""" + +import json +import logging +import os +import sys +from typing import Any + +current_script_dir = os.path.dirname(os.path.abspath(__file__)) +project_root_dir = os.path.abspath(os.path.join(current_script_dir, os.pardir)) + +if project_root_dir not in sys.path: + sys.path.append(project_root_dir) + +from airos.data import AirOS8Data, Remote, Station, Wireless # noqa: E402 + +logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) +_LOGGER = logging.getLogger(__name__) + + +def main(): + """Debug data.""" + if len(sys.argv) <= 1: + _LOGGER.info("Use with file to check") + raise Exception("File to check not provided.") + + current_script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root_dir = os.path.abspath(os.path.join(current_script_dir, os.pardir)) + + if project_root_dir not in sys.path: + sys.path.append(project_root_dir) + + # Load the JSON data + with open(sys.argv[1]) as f: + data = json.loads(f.read()) + + try: + _LOGGER.info("Attempting to deserialize Wireless object...") + wireless_data: dict[str, Any] = data["wireless"] + + _LOGGER.info(" -> Checking Wireless enums...") + wireless_data_prepped = Wireless.__pre_deserialize__(wireless_data.copy()) # noqa: F841 + _LOGGER.info( + " Success! Wireless enums (mode, ieeemode, security) are valid." + ) + + _LOGGER.info(" -> Checking list of Station objects...") + station_list_data = wireless_data["sta"] + station_obj_list = [] + for i, station_data in enumerate(station_list_data): + _LOGGER.info(" -> Checking Station object at index %s...", i) + remote_data = station_data["remote"] + _LOGGER.info(" -> Checking Remote object at index %s...", i) + _LOGGER.info("Remote data = %s", remote_data) + remote_obj = Remote.from_dict(remote_data) # noqa: F841 + _LOGGER.info(" Success! Remote is valid.") + + station_obj = Station.from_dict(station_data) + station_obj_list.append(station_obj) # noqa: F841 + _LOGGER.info(" Success! Station at index %s is valid.", i) + + _LOGGER.info(" -> Checking top-level Wireless object...") + wireless_obj = Wireless.from_dict(wireless_data) # noqa: F841 + _LOGGER.info(" -> Success! The Wireless object is valid.") + + _LOGGER.info("Attempting to deserialize full AirOS8Data object...") + airos_data_obj = AirOS8Data.from_dict(data) # noqa: F841 + _LOGGER.info("Success! Full AirOS8Data object is valid.") + + except Exception as e: + _LOGGER.info("\n------------------") + _LOGGER.info("CRITICAL ERROR FOUND!") + _LOGGER.info("The program failed at: %s", e) + _LOGGER.info("------------------\n") + + +if __name__ == "__main__": + main() From b626c19baef0d15aacf2cc41dfdb7e666b6141da Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 8 Aug 2025 21:34:04 +0200 Subject: [PATCH 3/3] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0912d08..085b13f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.2.7a0" +version = "0.2.7" license = "MIT" description = "Ubiquity airOS module(s) for Python 3." readme = "README.md"