diff --git a/README.md b/README.md index dedd61b..022f479 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ async def test_airos(): # Fetch status (large dict, including connected stations) result = await device.status() print(f"Result: {result}") + print(f"Result: {result.wireless.mode}") # Reconnect 'other side' result = await device.stakick("01:23:45:67:89:AB") print(f"Result: {result}") diff --git a/airos/airos8.py b/airos/airos8.py index a7461b4..97b7c24 100644 --- a/airos/airos8.py +++ b/airos/airos8.py @@ -7,7 +7,9 @@ from urllib.parse import urlparse import aiohttp +from mashumaro.exceptions import InvalidFieldValue, MissingField +from .airos8data import AirOSData from .exceptions import ( ConnectionAuthenticationError, ConnectionSetupError, @@ -185,7 +187,7 @@ async def login(self) -> bool: logger.exception("Error during login") raise DeviceConnectionError from err - async def status(self) -> dict: + async def status(self, return_json: bool = False) -> dict | AirOSData: """Retrieve status from the device.""" if not self.connected: logger.error("Not connected, login first") @@ -205,15 +207,22 @@ async def status(self) -> dict: try: response_text = await response.text() response_json = json.loads(response_text) - if ( - "host" not in response_json - or "device_id" not in response_json["host"] - ): - logger.error( - "Source data missing 'host' or 'device_id' keys" - ) - raise KeyDataMissingError from None - return response_json + try: + airos_data = AirOSData.from_dict(response_json) + except (MissingField, InvalidFieldValue) as err: + logger.exception("Failed to deserialize AirOS data") + raise KeyDataMissingError from err + + # Show new enums detected + if airos_data.warnings: + for field_name, messages in airos_data.warnings.items(): + for msg in messages: + log = f"AirOS data warning for field '{field_name}': {msg}" + logger.warning(log) + + if return_json: + return response_json + return airos_data except json.JSONDecodeError: logger.exception( "JSON Decode Error in authenticated status response" diff --git a/airos/airos8data.py b/airos/airos8data.py new file mode 100644 index 0000000..81d8783 --- /dev/null +++ b/airos/airos8data.py @@ -0,0 +1,430 @@ +"""Provide mashumaro data object for AirOSData.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from mashumaro import DataClassDictMixin + + +class IeeeMode(Enum): + """Enum definition.""" + + _11ACVHT80 = "11ACVHT80" + # More to be added when known + + +class WirelessMode(Enum): + """Enum definition.""" + + AccessPoint_PointToPoint = "ap-ptp" + Station_PointToPoint = "sta-ptp" + # More to be added when known + + +class Security(Enum): + """Enum definition.""" + + WPA2 = "WPA2" + # More to be added when known + + +class NetRole(Enum): + """Enum definition.""" + + BRIDGE = "bridge" + ROUTER = "router" + # More to be added when known + + +@dataclass +class ChainName: + """Leaf definition.""" + + number: int + name: str + + +@dataclass +class Host: + """Leaf definition.""" + + hostname: str + device_id: str + uptime: int + power_time: int + time: str + timestamp: int + fwversion: str + devmodel: str + netrole: NetRole | str + loadavg: float + totalram: int + freeram: int + temperature: int + cpuload: float + height: int + + +@dataclass +class Services: + """Leaf definition.""" + + dhcpc: bool + dhcpd: bool + dhcp6d_stateful: bool + pppoe: bool + airview: int + + +@dataclass +class Firewall: + """Leaf definition.""" + + iptables: bool + ebtables: bool + ip6tables: bool + eb6tables: bool + + +@dataclass +class Throughput: + """Leaf definition.""" + + tx: int + rx: int + + +@dataclass +class ServiceTime: + """Leaf definition.""" + + time: int + link: int + + +@dataclass +class Polling: + """Leaf definition.""" + + cb_capacity: int + dl_capacity: int + ul_capacity: int + use: int + tx_use: int + rx_use: int + atpc_status: int + fixed_frame: bool + gps_sync: bool + ff_cap_rep: bool + + +@dataclass +class Stats: + """Leaf definition.""" + + rx_bytes: int + rx_packets: int + rx_pps: int + tx_bytes: int + tx_packets: int + tx_pps: int + + +@dataclass +class EvmData: + """Leaf definition.""" + + usage: int + cinr: int + evm: list[list[int]] + + +@dataclass +class Airmax: + """Leaf definition.""" + + actual_priority: int + beam: int + desired_priority: int + cb_capacity: int + dl_capacity: int + ul_capacity: int + atpc_status: int + rx: EvmData + tx: EvmData + + +@dataclass +class EthList: + """Leaf definition.""" + + ifname: str + enabled: bool + plugged: bool + duplex: bool + speed: int + snr: list[int] + cable_len: int + + +@dataclass +class GPSData: + """Leaf definition.""" + + lat: str + lon: str + fix: int + + +@dataclass +class Remote: + """Leaf definition.""" + + age: int + device_id: str + hostname: str + platform: str + version: str + time: str + cpuload: float + temperature: int + totalram: int + freeram: int + netrole: str + mode: WirelessMode | str # Allow non-breaking future expansion + sys_id: str + tx_throughput: int + rx_throughput: int + uptime: int + power_time: int + compat_11n: int + signal: int + rssi: int + noisefloor: int + tx_power: int + distance: int + rx_chainmask: int + chainrssi: list[int] + tx_ratedata: list[int] + tx_bytes: int + rx_bytes: int + antenna_gain: int + cable_loss: int + height: int + ethlist: list[EthList] + ipaddr: list[str] + ip6addr: list[str] + gps: GPSData + oob: bool + unms: dict[str, Any] + airview: int + service: ServiceTime + + +@dataclass +class Station: + """Leaf definition.""" + + mac: str + lastip: str + signal: int + rssi: int + noisefloor: int + chainrssi: list[int] + tx_idx: int + rx_idx: int + tx_nss: int + rx_nss: int + tx_latency: int + distance: int + tx_packets: int + tx_lretries: int + tx_sretries: int + uptime: int + dl_signal_expect: int + ul_signal_expect: int + cb_capacity_expect: int + dl_capacity_expect: int + ul_capacity_expect: int + dl_rate_expect: int + ul_rate_expect: int + dl_linkscore: int + ul_linkscore: int + dl_avg_linkscore: int + ul_avg_linkscore: int + tx_ratedata: list[int] + stats: Stats + airmax: Airmax + last_disc: int + remote: Remote + + +@dataclass +class Wireless: + """Leaf definition.""" + + essid: str + mode: WirelessMode | str # Allow non-breaking expansion + ieeemode: IeeeMode | str # Allow non-breaking expansion + band: int + compat_11n: int + hide_essid: int + apmac: str + antenna_gain: int + frequency: int + center1_freq: int + dfs: int + distance: int + security: Security | str # Allow non-breaking expansion + noisef: int + txpower: int + aprepeater: bool + rstatus: int + chanbw: int + rx_chainmask: int + tx_chainmask: int + nol_state: int + nol_timeout: int + cac_state: int + cac_timeout: int + rx_idx: int + rx_nss: int + tx_idx: int + tx_nss: int + throughput: Throughput + service: ServiceTime + polling: Polling + count: int + sta: list[Station] + sta_disconnected: list[Any] + + +@dataclass +class InterfaceStatus: + """Leaf definition.""" + + plugged: bool + tx_bytes: int + rx_bytes: int + tx_packets: int + rx_packets: int + tx_errors: int + rx_errors: int + tx_dropped: int + rx_dropped: int + ipaddr: str + speed: int + duplex: bool + snr: list[int] | None = None + cable_len: int | None = None + ip6addr: list[dict[str, Any]] | None = None + + +@dataclass +class Interface: + """Leaf definition.""" + + ifname: str + hwaddr: str + enabled: bool + mtu: int + status: InterfaceStatus + + +@dataclass +class ProvisioningMode: + """Leaf definition.""" + + pass + + +@dataclass +class NtpClient: + """Leaf definition.""" + + pass + + +@dataclass +class UnmsStatus: + """Leaf definition.""" + + status: int + + +@dataclass +class GPSMain: + """Leaf definition.""" + + lat: float + lon: float + fix: int + + +@dataclass +class AirOSData(DataClassDictMixin): + """Dataclass for AirOS devices.""" + + chain_names: list[ChainName] + host: Host + genuine: str + services: Services + firewall: Firewall + portfw: bool + wireless: Wireless + interfaces: list[Interface] + provmode: ( + ProvisioningMode | str | dict[str, Any] | list[Any] | Any + ) # If it can be populated, define its fields + ntpclient: ( + NtpClient | str | dict[str, Any] | list[Any] | Any + ) # If it can be populated, define its fields + unms: UnmsStatus + gps: GPSMain + warnings: dict[str, list[str]] = field(default_factory=dict, init=False) + + @classmethod + def __post_deserialize__(cls, airos_object: "AirOSData") -> "AirOSData": + """Validate after deserialization.""" + airos_object.check_for_warnings() + return airos_object + + def check_for_warnings(self): + """Validate unions for unknown fields.""" + # Check wireless mode + if isinstance(self.wireless.mode, str): + self.add_warning( + "wireless", f"Unknown (new) wireless mode: '{self.wireless.mode}'" + ) + + # Check host netrole + if isinstance(self.host.netrole, str): + self.add_warning( + "host", f"Unknown (new) network role: '{self.host.netrole}'" + ) + + # Check wireless IEEE mode + if isinstance(self.wireless.ieeemode, str): + self.add_warning( + "wireless", f"Unknown (new) IEEE mode: '{self.wireless.ieeemode}'" + ) + + # Check wireless security + if isinstance(self.wireless.security, str): + self.add_warning( + "wireless", f"Unknown (new) security type: '{self.wireless.security}'" + ) + # Check station remote modes + for i, station in enumerate(self.wireless.sta): + if hasattr(station.remote, "mode") and isinstance(station.remote.mode, str): + self.add_warning( + f"wireless.sta[{i}].remote", + f"Unknown (new) remote mode: '{station.remote.mode}'", + ) + + def add_warning(self, field_name: str, message: str): + """Insert warnings into the dictionary on unknown field data.""" + if field_name not in self.warnings: + self.warnings[field_name] = [] + self.warnings[field_name].append(message) diff --git a/fixtures/ap-ptp.json b/fixtures/ap-ptp.json index b463908..dded8aa 100644 --- a/fixtures/ap-ptp.json +++ b/fixtures/ap-ptp.json @@ -1 +1 @@ -{"chain_names":[{"number":1,"name": "Chain 0"},{"number":2,"name": "Chain 1"}],"host":{"hostname": "NanoStation 5AC ap name ","device_id": "03aa0d0b40fed0a47088293584ef5432","uptime":264888,"power_time":268683,"time": "2025-06-23 23:06:42","timestamp":2668313184,"fwversion": "v8.7.17","devmodel": "NanoStation 5AC loco","netrole": "bridge","loadavg":0.412598,"totalram":63447040,"freeram":16564224,"temperature":0,"cpuload":10.101010,"height":3},"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": "ap-ptp","ieeemode": "11ACVHT80","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":-3,"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":8,"rx_nss":2,"tx_idx":9,"tx_nss":2,"throughput":{"tx":222,"rx":9907},"service":{"time":267181,"link":266003},"polling":{"cb_capacity":593970,"dl_capacity":647400,"ul_capacity":540540,"use":48,"tx_use":6,"rx_use":42,"atpc_status":2,"fixed_frame":false,"gps_sync":false,"ff_cap_rep":false},"count":1,"sta":[{"mac":"01:23:45:67:89:AB","lastip":"192.168.1.2","signal":-59,"rssi":37,"noisefloor":-89,"chainrssi":[35,32,0],"tx_idx":9,"rx_idx":8,"tx_nss":2,"rx_nss":2,"tx_latency":0,"distance":1,"tx_packets":0,"tx_lretries":0,"tx_sretries":0,"uptime":170281,"dl_signal_expect":-80,"ul_signal_expect":-55,"cb_capacity_expect":416000,"dl_capacity_expect":208000,"ul_capacity_expect":624000,"dl_rate_expect":3,"ul_rate_expect":8,"dl_linkscore":100,"ul_linkscore":86,"dl_avg_linkscore":100,"ul_avg_linkscore":88,"tx_ratedata":[175,4,47,200,673,158,163,138,68895,19577430],"stats":{"rx_bytes":206938324814,"rx_packets":149767200,"rx_pps":846,"tx_bytes":5265602739,"tx_packets":52980390,"tx_pps":0},"airmax":{"actual_priority":0,"beam":0,"desired_priority":0,"cb_capacity":593970,"dl_capacity":647400,"ul_capacity":540540,"atpc_status":2,"rx":{"usage":42,"cinr":31,"evm":[[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,29,35,31,33,32,29],[34,34,35,34,35,35,34,34,34,34,34,34,34,34,35,35,34,34,35,34,33,33,35,34,34,35,34,35,34,34,35,34,34,33,34,34,34,34,34,35,35,35,34,35,33,34,34,34,34,35,35,34,34,34,34,34,34,34,34,34,34,34,35,35]]},"tx":{"usage":6,"cinr":31,"evm":[[32,34,28,33,35,30,31,33,30,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],[37,37,37,38,38,37,36,38,38,37,37,37,37,37,39,37,37,37,37,37,37,36,37,37,37,37,37,37,37,38,37,37,38,37,37,37,38,37,38,37,37,37,37,37,36,37,37,37,37,37,37,38,37,37,38,37,36,37,37,37,37,37,37,37]]}},"last_disc":1,"remote":{"age":1,"device_id": "d4f4cdf82961e619328a8f72f8d7653b","hostname": "NanoStation 5AC sta name","platform": "NanoStation 5AC loco","version": "WA.ar934x.v8.7.17.48152.250620.2132","time": "2025-06-23 23:13:54","cpuload":43.564301,"temperature":0,"totalram":63447040,"freeram":14290944,"netrole": "bridge","mode": "sta-ptp","sys_id":"0xe7fa","tx_throughput":16023,"rx_throughput":251,"uptime":265320,"power_time":268512,"compat_11n":0,"signal":-58,"rssi":38,"noisefloor":-90,"tx_power":-4,"distance":1,"rx_chainmask":3,"chainrssi":[33,37,0],"tx_ratedata":[14,4,372,2223,4708,4037,8142,485763,29420892,24748154],"tx_bytes":212308148210,"rx_bytes":3624206478,"antenna_gain":13,"cable_loss":0,"height":2,"ethlist":[{"ifname": "eth0","enabled":true,"plugged":true,"duplex":true,"speed":1000,"snr":[30,30,29,30],"cable_len":14}],"ipaddr":["192.168.1.2"],"ip6addr":["fe80::eea:14ff:fea4:806"],"gps":{"lat": "52.020100","lon": "5.071400","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267195,"link":265996}}}],"sta_disconnected":[]},"interfaces":[{"ifname": "eth0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":209900085624,"rx_bytes":3984971949,"tx_packets":185866883,"rx_packets":73564835,"tx_errors":0,"rx_errors":4,"tx_dropped":10,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":1000,"duplex":true,"snr":[30,30,30,30],"cable_len":18}},{"ifname": "ath0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":false,"tx_bytes":5265602738,"rx_bytes":206938324766,"tx_packets":52980390,"rx_packets":149767200,"tx_errors":0,"rx_errors":0,"tx_dropped":2005,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":0,"duplex":false}},{"ifname": "br0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":236295176,"rx_bytes":204802727,"tx_packets":298119,"rx_packets":1791592,"tx_errors":0,"rx_errors":0,"tx_dropped":0,"rx_dropped":0,"ipaddr":"192.168.1.2","ip6addr":[{"addr":"fe80::eea:14ff:fea4:7b8","plen":64}],"speed":0,"duplex":false}}],"provmode":{},"ntpclient":{},"unms":{"status":0},"gps":{"lat":52.222651,"lon":4.532288,"fix":0}} +{"chain_names":[{"number":1,"name": "Chain 0"},{"number":2,"name": "Chain 1"}],"host":{"hostname": "NanoStation 5AC ap name ","device_id": "03aa0d0b40fed0a47088293584ef5432","uptime":264888,"power_time":268683,"time": "2025-06-23 23:06:42","timestamp":2668313184,"fwversion": "v8.7.17","devmodel": "NanoStation 5AC loco","netrole": "bridge","loadavg":0.412598,"totalram":63447040,"freeram":16564224,"temperature":0,"cpuload":10.101010,"height":3},"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": "ap-ptp","ieeemode": "11ACVHT80","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":-3,"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":8,"rx_nss":2,"tx_idx":9,"tx_nss":2,"throughput":{"tx":222,"rx":9907},"service":{"time":267181,"link":266003},"polling":{"cb_capacity":593970,"dl_capacity":647400,"ul_capacity":540540,"use":48,"tx_use":6,"rx_use":42,"atpc_status":2,"fixed_frame":false,"gps_sync":false,"ff_cap_rep":false},"count":1,"sta":[{"mac":"01:23:45:67:89:AB","lastip":"192.168.1.2","signal":-59,"rssi":37,"noisefloor":-89,"chainrssi":[35,32,0],"tx_idx":9,"rx_idx":8,"tx_nss":2,"rx_nss":2,"tx_latency":0,"distance":1,"tx_packets":0,"tx_lretries":0,"tx_sretries":0,"uptime":170281,"dl_signal_expect":-80,"ul_signal_expect":-55,"cb_capacity_expect":416000,"dl_capacity_expect":208000,"ul_capacity_expect":624000,"dl_rate_expect":3,"ul_rate_expect":8,"dl_linkscore":100,"ul_linkscore":86,"dl_avg_linkscore":100,"ul_avg_linkscore":88,"tx_ratedata":[175,4,47,200,673,158,163,138,68895,19577430],"stats":{"rx_bytes":206938324814,"rx_packets":149767200,"rx_pps":846,"tx_bytes":5265602739,"tx_packets":52980390,"tx_pps":0},"airmax":{"actual_priority":0,"beam":0,"desired_priority":0,"cb_capacity":593970,"dl_capacity":647400,"ul_capacity":540540,"atpc_status":2,"rx":{"usage":42,"cinr":31,"evm":[[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,29,35,31,33,32,29],[34,34,35,34,35,35,34,34,34,34,34,34,34,34,35,35,34,34,35,34,33,33,35,34,34,35,34,35,34,34,35,34,34,33,34,34,34,34,34,35,35,35,34,35,33,34,34,34,34,35,35,34,34,34,34,34,34,34,34,34,34,34,35,35]]},"tx":{"usage":6,"cinr":31,"evm":[[32,34,28,33,35,30,31,33,30,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],[37,37,37,38,38,37,36,38,38,37,37,37,37,37,39,37,37,37,37,37,37,36,37,37,37,37,37,37,37,38,37,37,38,37,37,37,38,37,38,37,37,37,37,37,36,37,37,37,37,37,37,38,37,37,38,37,36,37,37,37,37,37,37,37]]}},"last_disc":1,"remote":{"age":1,"device_id": "d4f4cdf82961e619328a8f72f8d7653b","hostname": "NanoStation 5AC sta name","platform": "NanoStation 5AC loco","version": "WA.ar934x.v8.7.17.48152.250620.2132","time": "2025-06-23 23:13:54","cpuload":43.564301,"temperature":0,"totalram":63447040,"freeram":14290944,"netrole": "bridge","mode": "sta-ptp","sys_id":"0xe7fa","tx_throughput":16023,"rx_throughput":251,"uptime":265320,"power_time":268512,"compat_11n":0,"signal":-58,"rssi":38,"noisefloor":-90,"tx_power":-4,"distance":1,"rx_chainmask":3,"chainrssi":[33,37,0],"tx_ratedata":[14,4,372,2223,4708,4037,8142,485763,29420892,24748154],"tx_bytes":212308148210,"rx_bytes":3624206478,"antenna_gain":13,"cable_loss":0,"height":2,"ethlist":[{"ifname": "eth0","enabled":true,"plugged":true,"duplex":true,"speed":1000,"snr":[30,30,29,30],"cable_len":14}],"ipaddr":["192.168.1.2"],"ip6addr":["fe80::eea:14ff:fea4:806"],"gps":{"lat": "52.020100","lon": "5.071400","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267195,"link":265996}}}],"sta_disconnected":[]},"interfaces":[{"ifname": "eth0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":209900085624,"rx_bytes":3984971949,"tx_packets":185866883,"rx_packets":73564835,"tx_errors":0,"rx_errors":4,"tx_dropped":10,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":1000,"duplex":true,"snr":[30,30,30,30],"cable_len":18}},{"ifname": "ath0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":false,"tx_bytes":5265602738,"rx_bytes":206938324766,"tx_packets":52980390,"rx_packets":149767200,"tx_errors":0,"rx_errors":0,"tx_dropped":2005,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":0,"duplex":false}},{"ifname": "br0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":236295176,"rx_bytes":204802727,"tx_packets":298119,"rx_packets":1791592,"tx_errors":0,"rx_errors":0,"tx_dropped":0,"rx_dropped":0,"ipaddr":"192.168.1.2","ip6addr":[{"addr":"fe80::eea:14ff:fea4:7b8","plen":64}],"speed":0,"duplex":false}}],"provmode":{},"ntpclient":{},"unms":{"status":0},"gps":{"lat":52.020100,"lon":5.0714000,"fix":0}} diff --git a/fixtures/sta-ptp.json b/fixtures/sta-ptp.json index dca620a..1f54cc3 100644 --- a/fixtures/sta-ptp.json +++ b/fixtures/sta-ptp.json @@ -1 +1 @@ -{"chain_names":[{"number":1,"name": "Chain 0"},{"number":2,"name": "Chain 1"}],"host":{"hostname": "NanoStation 5AC sta name","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-ptp","ieeemode": "AUTO","band":2,"compat_11n":0,"hide_essid":0,"apmac":"01:23:45:67:89:CD","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,"ff_cap_rep":false},"count":1,"sta":[{"mac":"01:23:45:67:89:CD","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 ","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-ptp","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:7b8"],"gps":{"lat": "52.020100","lon": "5.071400","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267234,"link":266056}}}],"sta_disconnected":[]},"interfaces":[{"ifname": "eth0","hwaddr":"0C:EA:14:A5:08:06","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":"0C:EA:14:A4:08:06","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":"0C:EA:14:A4:08:06","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:806","plen":64}],"speed":0,"duplex":false}}],"provmode":{},"ntpclient":{},"unms":{"status":0},"gps":{"lat":52.222651,"lon":4.532288,"fix":0}} +{"chain_names":[{"number":1,"name": "Chain 0"},{"number":2,"name": "Chain 1"}],"host":{"hostname": "NanoStation 5AC sta name","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-ptp","ieeemode": "AUTO","band":2,"compat_11n":0,"hide_essid":0,"apmac":"01:23:45:67:89:CD","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,"ff_cap_rep":false},"count":1,"sta":[{"mac":"01:23:45:67:89:CD","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 ","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-ptp","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:7b8"],"gps":{"lat": "52.020100","lon": "5.071400","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267234,"link":266056}}}],"sta_disconnected":[]},"interfaces":[{"ifname": "eth0","hwaddr":"0C:EA:14:A5:08:06","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":"0C:EA:14:A4:08:06","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":"0C:EA:14:A4:08:06","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:806","plen":64}],"speed":0,"duplex":false}}],"provmode":{},"ntpclient":{},"unms":{"status":0},"gps":{"lat":52.020100,"lon":5.0714000,"fix":0}} diff --git a/pyproject.toml b/pyproject.toml index ea3754b..862c528 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.1.1" +version = "0.1.2" license = "MIT" description = "Ubiquity airOS module(s) for Python 3." readme = "README.md" @@ -22,6 +22,7 @@ maintainers = [ requires-python = ">=3.13" dependencies = [ "aiohttp", + "mashumaro", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index bb41ec2..8ee6e17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ aiohttp==3.12.14 -asyncio==3.4.3 +mashumaro==3.16 diff --git a/tests/test_stations.py b/tests/test_stations.py index 259ee34..65c10b1 100644 --- a/tests/test_stations.py +++ b/tests/test_stations.py @@ -5,6 +5,7 @@ import os from unittest.mock import AsyncMock, MagicMock, patch +from airos.airos8data import AirOSData import airos.exceptions import pytest @@ -27,7 +28,7 @@ async def _read_fixture(fixture: str = "ap-ptp"): @pytest.mark.parametrize("mode", ["ap-ptp", "sta-ptp"]) @pytest.mark.asyncio -async def test_ap(airos_device, base_url, mode): +async def test_ap_json(airos_device, base_url, mode): """Test device operation.""" cookie = SimpleCookie() cookie["session_id"] = "test-cookie" @@ -54,12 +55,47 @@ async def test_ap(airos_device, base_url, mode): patch.object(airos_device.session, "get", return_value=mock_status_response), ): assert await airos_device.login() - status = await airos_device.status() + status = await airos_device.status(return_json=True) # Verify the fixture returns the correct mode assert status.get("wireless", {}).get("mode") == mode +@pytest.mark.parametrize("mode", ["ap-ptp", "sta-ptp"]) +@pytest.mark.asyncio +async def test_ap_object(airos_device, base_url, mode): + """Test device operation.""" + cookie = SimpleCookie() + cookie["session_id"] = "test-cookie" + cookie["AIROS_TOKEN"] = "abc123" + + # --- Prepare fake POST /api/auth response with cookies --- + mock_login_response = MagicMock() + mock_login_response.__aenter__.return_value = mock_login_response + mock_login_response.text = AsyncMock(return_value="{}") + mock_login_response.status = 200 + mock_login_response.cookies = cookie + mock_login_response.headers = {"X-CSRF-ID": "test-csrf-token"} + # --- Prepare fake GET /api/status response --- + fixture_data = await _read_fixture(mode) + mock_status_payload = fixture_data + mock_status_response = MagicMock() + mock_status_response.__aenter__.return_value = mock_status_response + mock_status_response.text = AsyncMock(return_value=json.dumps(fixture_data)) + mock_status_response.status = 200 + mock_status_response.json = AsyncMock(return_value=mock_status_payload) + + with ( + patch.object(airos_device.session, "post", return_value=mock_login_response), + patch.object(airos_device.session, "get", return_value=mock_status_response), + ): + assert await airos_device.login() + status: AirOSData = await airos_device.status() # Implies return_json = False + + # Verify the fixture returns the correct mode + assert status.wireless.mode.value == mode + + @pytest.mark.asyncio async def test_reconnect(airos_device, base_url): """Test reconnect client."""