diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index cfc5e4e..fd0c396 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -4,7 +4,6 @@ env: CACHE_VERSION: 1 DEFAULT_PYTHON: "3.13" -# Only run on merges on: pull_request: types: closed @@ -12,32 +11,37 @@ on: - main jobs: - publishing: - name: Build and publish Python 🐍 distributions 📦 to PyPI + determine_version: + name: Determine Package Version runs-on: ubuntu-latest - environment: pypi + outputs: + package_version: ${{ steps.get_version.outputs.package_version }} + should_publish: ${{ steps.scheck_pypi.outputs.should_publish }} permissions: - contents: read # Required by actions/checkout - id-token: write # Needed for OIDC-based Trusted Publishing - # Only trigger on merges, not just closes + contents: read if: github.event.pull_request.merged == true steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Prepare uv - run: | - pip install uv - uv venv --seed venv - . venv/bin/activate - uv pip install toml - - name: Check for existing package on PyPI - id: check_package + - name: Get Package Version from pyproject.toml + id: get_version run: | - . venv/bin/activate + # Install toml to parse pyproject.toml + pip install toml PACKAGE_VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") PACKAGE_NAME=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['name'])") - echo "Checking for package: $PACKAGE_NAME==$PACKAGE_VERSION" + # Set outputs for the next jobs + echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT" + echo "package_name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" + echo "Package name and version: $PACKAGE_NAME==$PACKAGE_VERSION" + + - name: Check for existing package on PyPI + id: scheck_pypi + run: | + # Using the package name and version from the previous step + PACKAGE_VERSION=${{ steps.get_version.outputs.package_version }} + PACKAGE_NAME=${{ steps.get_version.outputs.package_name }} if curl -s "https://pypi.org/pypi/$PACKAGE_NAME/json" | jq -r '.releases | keys[]' | grep -q "^$PACKAGE_VERSION$"; then echo "Package version already exists. Skipping upload." @@ -46,13 +50,68 @@ jobs: echo "Package version does not exist. Proceeding with upload." echo "should_publish=true" >> $GITHUB_OUTPUT fi + + publishing: + name: Build and publish Python 🐍 distributions 📦 to PyPI + runs-on: ubuntu-latest + needs: determine_version + if: needs.determine_version.outputs.should_publish == 'true' + environment: pypi + permissions: + contents: read + id-token: write + steps: + - name: Check out committed code + uses: actions/checkout@v4 + - name: Prepare uv + run: | + pip install uv + uv venv --seed venv + . venv/bin/activate - name: Build - if: steps.check_package.outputs.should_publish == 'true' run: | . venv/bin/activate uv build - name: Publish distribution 📦 to PyPI - if: steps.check_package.outputs.should_publish == 'true' run: | . venv/bin/activate uv publish + + create_tag: + name: Create Git Tag for Release + runs-on: ubuntu-latest + needs: [determine_version, publishing] + if: always() && needs.publishing.result == 'success' && needs.determine_version.outputs.should_publish == 'true' + permissions: + contents: write + steps: + - name: Check out committed code + uses: actions/checkout@v4 + - name: Create release tag + id: tag_release + uses: actions/github-script@v6 + with: + script: | + const version = process.env.PACKAGE_VERSION; + const tagName = `v${version}`; + + console.log(`Attempting to create tag: ${tagName}`); + + try { + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/tags/${tagName}`, + sha: context.sha, + }); + console.log(`Successfully created tag: ${tagName}`); + } catch (error) { + console.error(`Failed to create tag: ${error.message}`); + if (error.status === 422) { + console.log(`Tag ${tagName} already exists. Skipping creation.`); + } else { + throw error; + } + } + env: + PACKAGE_VERSION: ${{ needs.determine_version.outputs.package_version }} diff --git a/.gitignore b/.gitignore index 8a42694..5e1335d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ tests/__pycache__ .coverage tmp todo +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 8903427..e32147e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.2.5] - 2025-08-05 + +### Added + +- Added booleans determining station/accesspoint and PTP/PTMP in derived subclass + ## [0.2.4] - 2025-08-03 ### Added diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 0000000..bd501f4 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,37 @@ +# Contributing + +It would be very helpful if you would share your configuration data to this project. This way we can make sure that the data returned by your airOS devices is processed correctly. + +## Current fixtures + +We currently have data on + +- Nanostation 5AC (LOCO5AC) - PTP - both AP and Station output of `/status.cgi` present (by @CoMPaTech) + +## Secure your data + +The best way to share your data is to remove any data that is not necessary for processing. To ensure you don't share any data by accident please follow the following + +- Log in to your device +- Note down the following options set + - Is it a station or access point + - Is it PTP (or PTMP) + - Channel width in Mhz +- Manually update the url to `/status.cgi`, e.g. `https://192.168.1.10/status.cgi` +- Store the output (for instance in an editor, even notepad would be fine) for processing + +**NOTE**: when redacting, redact in a meaningful way by changing parameters, don't put text in number fields or vice-versa! + +- First and foremost: find any `lat` and `lon` information and redact these but keep them as floats. (I.e. a value with decimals)! + - There are potentially multiple of these (so keep searching) + - If you are unsure, apply them as `(...)"lat":52.379894,"lon":4.901608,(...)` to point to Amsterdam +- Redact your IP addresses, especially public IPs (if present), + - Search/replace your range, say your AP is at `192.168.1.10` then search for `192.168.1.` and replace with `127.0.0.` + - Set them to `127.0.0.xxx` leaving the actual last octet what it was, just so devices are still different from **and** coheren to each other. +- Redact your SSID, just name it WirelessABC (as long as it's still coherent) +- Make sure your `hostname`s don't disclose unwanted information +- You may redact your MAC addresses (hwaddr, mac, etc) just make sure they are still valid (`00:11:22:33:44:55`) and coherent (so make sure local and remote(s) still differ) + +### Examples + +See `fixtures/userdata` for examples of shared information diff --git a/airos/airos8.py b/airos/airos8.py index 7c2f6a7..8c00a22 100644 --- a/airos/airos8.py +++ b/airos/airos8.py @@ -196,6 +196,30 @@ def derived_data( self, response: dict[str, Any] | None = None ) -> dict[str, Any] | None: """Add derived data to the device response.""" + derived = { + "station": False, + "access_point": False, + "ptp": False, + "ptmp": False, + } + + # Access Point / Station vs PTP/PtMP + wireless_mode = response.get("wireless", {}).get("mode", "") + match wireless_mode: + case "ap-ptmp": + derived["access_point"] = True + derived["ptmp"] = True + case "sta-ptmp": + derived["station"] = True + derived["ptmp"] = True + case "ap-ptp": + derived["access_point"] = True + derived["ptp"] = True + case "sta-ptp": + derived["station"] = True + derived["ptp"] = True + + # INTERFACES addresses = {} interface_order = ["br0", "eth0", "ath0"] @@ -209,19 +233,18 @@ def derived_data( if interface["enabled"]: # Only consider if enabled addresses[interface["ifname"]] = interface["hwaddr"] + # Fallback take fist alternate interface found + derived["mac"] = interfaces[0]["hwaddr"] + derived["mac_interface"] = interfaces[0]["ifname"] + for interface in interface_order: if interface in addresses: - response["derived"] = { - "mac": addresses[interface], - "mac_interface": interface, - } - return response + derived["mac"] = addresses[interface] + derived["mac_interface"] = interface + break + + response["derived"] = derived - # Fallback take fist alternate interface found - response["derived"] = { - "mac": interfaces[0]["hwaddr"], - "mac_interface": interfaces[0]["ifname"], - } return response async def status(self) -> AirOSData: diff --git a/airos/data.py b/airos/data.py index 95009f6..522d57a 100644 --- a/airos/data.py +++ b/airos/data.py @@ -44,8 +44,9 @@ class IeeeMode(Enum): class WirelessMode(Enum): """Enum definition.""" - PTP_ACCESSPOINT = "ap-ptp" PTMP_ACCESSPOINT = "ap-ptmp" + PTMP_STATION = "sta-ptmp" + PTP_ACCESSPOINT = "ap-ptp" PTP_STATION = "sta-ptp" # More to be added when known @@ -437,6 +438,14 @@ class Derived: mac: str # Base device MAC address (i.e. eth0) mac_interface: str # Interface derived from + # Split for WirelessMode + station: bool + access_point: bool + + # Split for WirelessMode + ptp: bool + ptmp: bool + @dataclass class AirOS8Data(DataClassDictMixin): diff --git a/airos/discovery.py b/airos/discovery.py index 8e7393c..469980c 100644 --- a/airos/discovery.py +++ b/airos/discovery.py @@ -175,14 +175,13 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None offset += 2 if tlv_length > (len(data) - offset): - log = f"TLV type {tlv_type:#x} length {tlv_length} exceeds remaining data " - _LOGGER.warning(log) - log = f"({len(data) - offset} bytes left). Packet malformed. " - _LOGGER.warning(log) - log = f"Data from TLV start: {data[offset - 3 :].hex()}" + log = ( + f"TLV type {tlv_type:#x} length {tlv_length} exceeds remaining data " + f"({len(data) - offset} bytes left). Packet malformed. " + f"Data from TLV start: {data[offset - 3 :].hex()}" + ) _LOGGER.warning(log) - log = f"Malformed packet: {log}" - raise AirOSEndpointError(log) + raise AirOSEndpointError(f"Malformed packet: {log}") tlv_value: bytes = data[offset : offset + tlv_length] @@ -195,6 +194,7 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None else: log = f"Unexpected length for 0x02 TLV (MAC+IP). Expected 10, got {tlv_length}. Value: {tlv_value.hex()}" _LOGGER.warning(log) + raise AirOSEndpointError(f"Malformed packet: {log}") elif tlv_type == 0x03: parsed_info["firmware_version"] = tlv_value.decode( @@ -213,6 +213,7 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None else: log = f"Unexpected length for Uptime (Type 0x0A): {tlv_length}. Value: {tlv_value.hex()}" _LOGGER.warning(log) + raise AirOSEndpointError(f"Malformed packet: {log}") elif tlv_type == 0x0B: parsed_info["hostname"] = tlv_value.decode( diff --git a/fixtures/airos_ap-ptp.json b/fixtures/airos_ap-ptp.json deleted file mode 100644 index bc0bdf4..0000000 --- a/fixtures/airos_ap-ptp.json +++ /dev/null @@ -1 +0,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.10101, "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:89ab"], "gps": {"lat": "52.379894", "lon": "4.901608", "fix": 0}, "oob": false, "unms": {"status": 0, "timestamp": null}, "airview": 2, "service": {"time": 267195, "link": 265996}}, "airos_connected": true}], "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, "ip6addr": null}}, {"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, "snr": null, "cable_len": null, "ip6addr": null}}, {"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", "speed": 0, "duplex": false, "snr": null, "cable_len": null, "ip6addr": [{"addr": "fe80::eea:14ff:fea4:89cd", "plen": 64}]}}], "provmode": {}, "ntpclient": {}, "unms": {"status": 0, "timestamp": null}, "gps": {"lat": 52.379894, "lon": 4.901608, "fix": 0}, "derived": {"mac": "01:23:45:67:89:AB", "mac_interface": "br0"}} \ No newline at end of file diff --git a/fixtures/airos_loco5ac_ap-ptp.json b/fixtures/airos_loco5ac_ap-ptp.json new file mode 100644 index 0000000..2a5905e --- /dev/null +++ b/fixtures/airos_loco5ac_ap-ptp.json @@ -0,0 +1,623 @@ +{ + "chain_names": [ + { + "name": "Chain 0", + "number": 1 + }, + { + "name": "Chain 1", + "number": 2 + } + ], + "derived": { + "access_point": true, + "mac": "01:23:45:67:89:AB", + "mac_interface": "br0", + "ptmp": false, + "ptp": true, + "station": false + }, + "firewall": { + "eb6tables": false, + "ebtables": false, + "ip6tables": false, + "iptables": false + }, + "genuine": "/images/genuine.png", + "gps": { + "fix": 0, + "lat": 52.379894, + "lon": 4.901608 + }, + "host": { + "cpuload": 10.10101, + "device_id": "03aa0d0b40fed0a47088293584ef5432", + "devmodel": "NanoStation 5AC loco", + "freeram": 16564224, + "fwversion": "v8.7.17", + "height": 3, + "hostname": "NanoStation 5AC ap name", + "loadavg": 0.412598, + "netrole": "bridge", + "power_time": 268683, + "temperature": 0, + "time": "2025-06-23 23:06:42", + "timestamp": 2668313184, + "totalram": 63447040, + "uptime": 264888 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "eth0", + "mtu": 1500, + "status": { + "cable_len": 18, + "duplex": true, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": true, + "rx_bytes": 3984971949, + "rx_dropped": 0, + "rx_errors": 4, + "rx_packets": 73564835, + "snr": [ + 30, + 30, + 30, + 30 + ], + "speed": 1000, + "tx_bytes": 209900085624, + "tx_dropped": 10, + "tx_errors": 0, + "tx_packets": 185866883 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "ath0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": false, + "rx_bytes": 206938324766, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 149767200, + "snr": null, + "speed": 0, + "tx_bytes": 5265602738, + "tx_dropped": 2005, + "tx_errors": 0, + "tx_packets": 52980390 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "br0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": [ + { + "addr": "fe80::eea:14ff:fea4:89cd", + "plen": 64 + } + ], + "ipaddr": "192.168.1.2", + "plugged": true, + "rx_bytes": 204802727, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 1791592, + "snr": null, + "speed": 0, + "tx_bytes": 236295176, + "tx_dropped": 0, + "tx_errors": 0, + "tx_packets": 298119 + } + } + ], + "ntpclient": {}, + "portfw": false, + "provmode": {}, + "services": { + "airview": 2, + "dhcp6d_stateful": false, + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 0, + "timestamp": null + }, + "wireless": { + "antenna_gain": 13, + "apmac": "01:23:45:67:89:AB", + "aprepeater": false, + "band": 2, + "cac_state": 0, + "cac_timeout": 0, + "center1_freq": 5530, + "chanbw": 80, + "compat_11n": 0, + "count": 1, + "dfs": 1, + "distance": 0, + "essid": "DemoSSID", + "frequency": 5500, + "hide_essid": 0, + "ieeemode": "11ACVHT80", + "mode": "ap-ptp", + "noisef": -89, + "nol_state": 0, + "nol_timeout": 0, + "polling": { + "atpc_status": 2, + "cb_capacity": 593970, + "dl_capacity": 647400, + "ff_cap_rep": false, + "fixed_frame": false, + "gps_sync": false, + "rx_use": 42, + "tx_use": 6, + "ul_capacity": 540540, + "use": 48 + }, + "rstatus": 5, + "rx_chainmask": 3, + "rx_idx": 8, + "rx_nss": 2, + "security": "WPA2", + "service": { + "link": 266003, + "time": 267181 + }, + "sta": [ + { + "airmax": { + "actual_priority": 0, + "atpc_status": 2, + "beam": 0, + "cb_capacity": 593970, + "desired_priority": 0, + "dl_capacity": 647400, + "rx": { + "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 + ] + ], + "usage": 42 + }, + "tx": { + "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 + ] + ], + "usage": 6 + }, + "ul_capacity": 540540 + }, + "airos_connected": true, + "cb_capacity_expect": 416000, + "chainrssi": [ + 35, + 32, + 0 + ], + "distance": 1, + "dl_avg_linkscore": 100, + "dl_capacity_expect": 208000, + "dl_linkscore": 100, + "dl_rate_expect": 3, + "dl_signal_expect": -80, + "last_disc": 1, + "lastip": "192.168.1.2", + "mac": "01:23:45:67:89:AB", + "noisefloor": -89, + "remote": { + "age": 1, + "airview": 2, + "antenna_gain": 13, + "cable_loss": 0, + "chainrssi": [ + 33, + 37, + 0 + ], + "compat_11n": 0, + "cpuload": 43.564301, + "device_id": "d4f4cdf82961e619328a8f72f8d7653b", + "distance": 1, + "ethlist": [ + { + "cable_len": 14, + "duplex": true, + "enabled": true, + "ifname": "eth0", + "plugged": true, + "snr": [ + 30, + 30, + 29, + 30 + ], + "speed": 1000 + } + ], + "freeram": 14290944, + "gps": { + "fix": 0, + "lat": "52.379894", + "lon": "4.901608" + }, + "height": 2, + "hostname": "NanoStation 5AC sta name", + "ip6addr": [ + "fe80::eea:14ff:fea4:89ab" + ], + "ipaddr": [ + "192.168.1.2" + ], + "mode": "sta-ptp", + "netrole": "bridge", + "noisefloor": -90, + "oob": false, + "platform": "NanoStation 5AC loco", + "power_time": 268512, + "rssi": 38, + "rx_bytes": 3624206478, + "rx_chainmask": 3, + "rx_throughput": 251, + "service": { + "link": 265996, + "time": 267195 + }, + "signal": -58, + "sys_id": "0xe7fa", + "temperature": 0, + "time": "2025-06-23 23:13:54", + "totalram": 63447040, + "tx_bytes": 212308148210, + "tx_power": -4, + "tx_ratedata": [ + 14, + 4, + 372, + 2223, + 4708, + 4037, + 8142, + 485763, + 29420892, + 24748154 + ], + "tx_throughput": 16023, + "unms": { + "status": 0, + "timestamp": null + }, + "uptime": 265320, + "version": "WA.ar934x.v8.7.17.48152.250620.2132" + }, + "rssi": 37, + "rx_idx": 8, + "rx_nss": 2, + "signal": -59, + "stats": { + "rx_bytes": 206938324814, + "rx_packets": 149767200, + "rx_pps": 846, + "tx_bytes": 5265602739, + "tx_packets": 52980390, + "tx_pps": 0 + }, + "tx_idx": 9, + "tx_latency": 0, + "tx_lretries": 0, + "tx_nss": 2, + "tx_packets": 0, + "tx_ratedata": [ + 175, + 4, + 47, + 200, + 673, + 158, + 163, + 138, + 68895, + 19577430 + ], + "tx_sretries": 0, + "ul_avg_linkscore": 88, + "ul_capacity_expect": 624000, + "ul_linkscore": 86, + "ul_rate_expect": 8, + "ul_signal_expect": -55, + "uptime": 170281 + } + ], + "sta_disconnected": [], + "throughput": { + "rx": 9907, + "tx": 222 + }, + "tx_chainmask": 3, + "tx_idx": 9, + "tx_nss": 2, + "txpower": -3 + } +} \ No newline at end of file diff --git a/fixtures/airos_loco5ac_sta-ptp.json b/fixtures/airos_loco5ac_sta-ptp.json new file mode 100644 index 0000000..ff2115a --- /dev/null +++ b/fixtures/airos_loco5ac_sta-ptp.json @@ -0,0 +1,623 @@ +{ + "chain_names": [ + { + "name": "Chain 0", + "number": 1 + }, + { + "name": "Chain 1", + "number": 2 + } + ], + "derived": { + "access_point": false, + "mac": "01:23:45:67:89:CD", + "mac_interface": "br0", + "ptmp": false, + "ptp": true, + "station": true + }, + "firewall": { + "eb6tables": false, + "ebtables": false, + "ip6tables": false, + "iptables": false + }, + "genuine": "/images/genuine.png", + "gps": { + "fix": 0, + "lat": 52.379894, + "lon": 4.901608 + }, + "host": { + "cpuload": 44.0, + "device_id": "d4f4cdf82961e619328a8f72f8d7653b", + "devmodel": "NanoStation 5AC loco", + "freeram": 16105472, + "fwversion": "v8.7.17", + "height": 2, + "hostname": "NanoStation 5AC sta name", + "loadavg": 0.359863, + "netrole": "bridge", + "power_time": 268567, + "temperature": 0, + "time": "2025-06-23 23:14:49", + "timestamp": 2668800167, + "totalram": 63447040, + "uptime": 265375 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "01:23:45:67:89:CD", + "ifname": "eth0", + "mtu": 1500, + "status": { + "cable_len": 14, + "duplex": true, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": true, + "rx_bytes": 206979884583, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 185401454, + "snr": [ + 30, + 30, + 30, + 30 + ], + "speed": 1000, + "tx_bytes": 4975926283, + "tx_dropped": 0, + "tx_errors": 0, + "tx_packets": 73329864 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:CD", + "ifname": "ath0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": false, + "rx_bytes": 3625607638, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 53038237, + "snr": null, + "speed": 0, + "tx_bytes": 212398179151, + "tx_dropped": 34, + "tx_errors": 0, + "tx_packets": 149906916 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:CD", + "ifname": "br0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": [ + { + "addr": "fe80::eea:14ff:fea4:89ab", + "plen": 64 + } + ], + "ipaddr": "192.168.1.3", + "plugged": true, + "rx_bytes": 198175800, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 1753443, + "snr": null, + "speed": 0, + "tx_bytes": 143856488, + "tx_dropped": 0, + "tx_errors": 0, + "tx_packets": 204749 + } + } + ], + "ntpclient": {}, + "portfw": false, + "provmode": {}, + "services": { + "airview": 2, + "dhcp6d_stateful": false, + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 0, + "timestamp": null + }, + "wireless": { + "antenna_gain": 13, + "apmac": "01:23:45:67:89:AB", + "aprepeater": false, + "band": 2, + "cac_state": 0, + "cac_timeout": 0, + "center1_freq": 5530, + "chanbw": 80, + "compat_11n": 0, + "count": 1, + "dfs": 1, + "distance": 0, + "essid": "DemoSSID", + "frequency": 5500, + "hide_essid": 0, + "ieeemode": "AUTO", + "mode": "sta-ptp", + "noisef": -89, + "nol_state": 0, + "nol_timeout": 0, + "polling": { + "atpc_status": 2, + "cb_capacity": 586950, + "dl_capacity": 647400, + "ff_cap_rep": false, + "fixed_frame": false, + "gps_sync": false, + "rx_use": 6, + "tx_use": 40, + "ul_capacity": 526500, + "use": 46 + }, + "rstatus": 5, + "rx_chainmask": 3, + "rx_idx": 9, + "rx_nss": 2, + "security": "WPA2", + "service": { + "link": 266051, + "time": 267250 + }, + "sta": [ + { + "airmax": { + "actual_priority": 0, + "atpc_status": 2, + "beam": 0, + "cb_capacity": 586950, + "desired_priority": 0, + "dl_capacity": 647400, + "rx": { + "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 + ] + ], + "usage": 6 + }, + "tx": { + "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 + ] + ], + "usage": 40 + }, + "ul_capacity": 526500 + }, + "airos_connected": true, + "cb_capacity_expect": 658000, + "chainrssi": [ + 33, + 37, + 0 + ], + "distance": 1, + "dl_avg_linkscore": 93, + "dl_capacity_expect": 692000, + "dl_linkscore": 93, + "dl_rate_expect": 9, + "dl_signal_expect": -45, + "last_disc": 1, + "lastip": "192.168.1.3", + "mac": "01:23:45:67:89:CD", + "noisefloor": -89, + "remote": { + "age": 2, + "airview": 2, + "antenna_gain": 13, + "cable_loss": 0, + "chainrssi": [ + 35, + 31, + 0 + ], + "compat_11n": 0, + "cpuload": 5.0505, + "device_id": "03aa0d0b40fed0a47088293584ef4418", + "distance": 1, + "ethlist": [ + { + "cable_len": 18, + "duplex": true, + "enabled": true, + "ifname": "eth0", + "plugged": true, + "snr": [ + 30, + 30, + 30, + 30 + ], + "speed": 1000 + } + ], + "freeram": 16633856, + "gps": { + "fix": 0, + "lat": "52.379894", + "lon": "4.901608" + }, + "height": 3, + "hostname": "NanoStation 5AC ap name", + "ip6addr": [ + "fe80::eea:14ff:fea4:89cd" + ], + "ipaddr": [ + "192.168.1.3" + ], + "mode": "ap-ptp", + "netrole": "bridge", + "noisefloor": -89, + "oob": false, + "platform": "NanoStation 5AC loco", + "power_time": 268736, + "rssi": 36, + "rx_bytes": 207021597130, + "rx_chainmask": 3, + "rx_throughput": 10548, + "service": { + "link": 266056, + "time": 267234 + }, + "signal": -60, + "sys_id": "0xe7fa", + "temperature": 0, + "time": "2025-06-23 23:07:34", + "totalram": 63447040, + "tx_bytes": 5267487876, + "tx_power": -3, + "tx_ratedata": [ + 175, + 4, + 47, + 200, + 673, + 158, + 163, + 138, + 68926, + 19583506 + ], + "tx_throughput": 314, + "unms": { + "status": 0, + "timestamp": null + }, + "uptime": 264941, + "version": "WA.ar934x.v8.7.17.48152.250620.2132" + }, + "rssi": 38, + "rx_idx": 9, + "rx_nss": 2, + "signal": -58, + "stats": { + "rx_bytes": 3622839202, + "rx_packets": 52999540, + "rx_pps": 446, + "tx_bytes": 212303079651, + "tx_packets": 149832292, + "tx_pps": 0 + }, + "tx_idx": 8, + "tx_latency": 0, + "tx_lretries": 0, + "tx_nss": 2, + "tx_packets": 0, + "tx_ratedata": [ + 14, + 4, + 372, + 2223, + 4708, + 4037, + 8142, + 486840, + 29437014, + 24752069 + ], + "tx_sretries": 0, + "ul_avg_linkscore": 87, + "ul_capacity_expect": 624000, + "ul_linkscore": 84, + "ul_rate_expect": 8, + "ul_signal_expect": -59, + "uptime": 170335 + } + ], + "sta_disconnected": [], + "throughput": { + "rx": 267, + "tx": 12014 + }, + "tx_chainmask": 3, + "tx_idx": 8, + "tx_nss": 2, + "txpower": -4 + } +} \ No newline at end of file diff --git a/fixtures/ap-ptp.json b/fixtures/userdata/loco5ac_ap-ptp.json similarity index 100% rename from fixtures/ap-ptp.json rename to fixtures/userdata/loco5ac_ap-ptp.json diff --git a/fixtures/sta-ptp.json b/fixtures/userdata/loco5ac_sta-ptp.json similarity index 100% rename from fixtures/sta-ptp.json rename to fixtures/userdata/loco5ac_sta-ptp.json diff --git a/pyproject.toml b/pyproject.toml index 9c6e28f..6e7b3d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.2.4" +version = "0.2.5" 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 338910a..84d3228 100644 --- a/script/generate_ha_fixture.py +++ b/script/generate_ha_fixture.py @@ -17,11 +17,21 @@ # Define the path to save the fixture fixture_dir = os.path.join(os.path.dirname(__file__), "../fixtures") -new_fixture_path = os.path.join(fixture_dir, "airos_ap-ptp.json") -base_fixture_path = os.path.join(fixture_dir, "ap-ptp.json") +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) - new.write(json.dumps(new_data.to_dict())) + 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) diff --git a/tests/test_airos8.py b/tests/test_airos8.py index 986f155..5f5b01d 100644 --- a/tests/test_airos8.py +++ b/tests/test_airos8.py @@ -8,6 +8,7 @@ import pytest import aiohttp +from mashumaro.exceptions import MissingField # --- Tests for Login and Connection Errors --- @@ -224,3 +225,36 @@ async def test_provmode_connection_error(airos_device): pytest.raises(airos.exceptions.AirOSDeviceConnectionError), ): await airos_device.provmode(active=True) + + +@pytest.mark.asyncio +async def test_status_missing_required_key_in_json(airos_device): + """Test status() with a response missing a key required by the dataclass.""" + airos_device.connected = True + # Fixture is valid JSON, but is missing the entire 'wireless' block, + # which is a required field for the AirOS8Data dataclass. + invalid_data = { + "host": {"hostname": "test"}, + "interfaces": [ + {"ifname": "br0", "hwaddr": "11:22:33:44:55:66", "enabled": True} + ], + } + + mock_status_response = MagicMock() + mock_status_response.__aenter__.return_value = mock_status_response + mock_status_response.text = AsyncMock(return_value=json.dumps(invalid_data)) + mock_status_response.status = 200 + + with ( + patch.object(airos_device.session, "get", return_value=mock_status_response), + patch("airos.airos8._LOGGER.exception") as mock_log_exception, + pytest.raises(airos.exceptions.AirOSKeyDataMissingError) as excinfo, + ): + await airos_device.status() + + # Check that the specific mashumaro error is logged and caught + mock_log_exception.assert_called_once() + assert "Failed to deserialize AirOS data" in mock_log_exception.call_args[0][0] + # --- MODIFICATION START --- + # Assert that the cause of our exception is the correct type from mashumaro + assert isinstance(excinfo.value.__cause__, MissingField) diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..c4aad42 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,54 @@ +"""Tests for airos data module.""" + +from unittest.mock import patch + +from airos.data import Host, Wireless +import pytest + + +@pytest.mark.asyncio +async def test_unknown_enum_values(): + """Test that unknown enum values are handled gracefully.""" + # 1. Test for Host.netrole + host_data = {"netrole": "unsupported_role", "other_field": "value"} + format_string = ( + "Unknown value '%s' for %s.%s. Please report at " + "https://github.com/CoMPaTech/python-airos/issues so we can add support." + ) + with patch("airos.data.logger.warning") as mock_warning: + processed_host = Host.__pre_deserialize__(host_data.copy()) + # Verify the unknown value was removed + assert "netrole" not in processed_host + # Verify the other fields remain + assert "other_field" in processed_host + # Verify a warning was logged + mock_warning.assert_called_once_with( + format_string, "unsupported_role", "Host", "netrole" + ) + + # 2. Test for Wireless (all enums) + wireless_data = { + "mode": "unsupported_mode", + "ieeemode": "unsupported_ieee", + "security": "unsupported_security", + "other_field": "value", + } + with patch("airos.data.logger.warning") as mock_warning: + processed_wireless = Wireless.__pre_deserialize__(wireless_data.copy()) + # Verify the unknown values were removed + assert "mode" not in processed_wireless + assert "ieeemode" not in processed_wireless + assert "security" not in processed_wireless + # Verify the other field remains + assert "other_field" in processed_wireless + # Verify warnings were logged for each unknown enum + assert mock_warning.call_count == 3 + mock_warning.assert_any_call( + format_string, "unsupported_mode", "Wireless", "mode" + ) + mock_warning.assert_any_call( + format_string, "unsupported_ieee", "Wireless", "ieeemode" + ) + mock_warning.assert_any_call( + format_string, "unsupported_security", "Wireless", "security" + ) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index dc142e8..72eb032 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -304,3 +304,76 @@ async def test_async_discover_devices_cancelled(mock_datagram_endpoint): assert "cannot_connect" in str(excinfo.value) mock_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_datagram_received_handles_general_exception(): + """Test datagram_received handles a generic exception during parsing.""" + mock_callback = AsyncMock() + protocol = AirOSDiscoveryProtocol(mock_callback) + some_data = b"\x01\x06\x00\x00\x00\x00" + host_ip = "192.168.1.100" + + with ( + patch.object( + protocol, "parse_airos_packet", side_effect=ValueError("A generic error") + ) as mock_parse, + patch("airos.discovery._LOGGER.exception") as mock_log_exception, + ): + # A generic exception should be caught and re-raised as AirOSDiscoveryError + with pytest.raises(AirOSDiscoveryError): + protocol.datagram_received(some_data, (host_ip, DISCOVERY_PORT)) + + mock_parse.assert_called_once_with(some_data, host_ip) + mock_callback.assert_not_called() + mock_log_exception.assert_called_once() + assert ( + "Error processing AirOS discovery packet" + in mock_log_exception.call_args[0][0] + ) + + +@pytest.mark.parametrize( + "packet_fragment, error_message", + [ + # Case 1: TLV type 0x0A (Uptime) with wrong length + (b"\x0a\x00\x02\x01\x02", "Unexpected length for Uptime (Type 0x0A)"), + # Case 2: TLV declared length exceeds remaining packet data + (b"\x0c\x00\xff\x41\x42", "length 255 exceeds remaining data"), + # Case 3: An unknown TLV type + (b"\xff\x01\x02", "Unhandled TLV type: 0xff"), + ], +) +@pytest.mark.asyncio +async def test_parse_airos_packet_tlv_edge_cases(packet_fragment, error_message): + """Test parsing of various malformed TLV entries.""" + protocol = AirOSDiscoveryProtocol(AsyncMock()) + # A valid header is required to get to the TLV parsing stage + base_packet = b"\x01\x06\x00\x00\x00\x00" + malformed_packet = base_packet + packet_fragment + host_ip = "192.168.1.100" + + with pytest.raises(AirOSEndpointError) as excinfo: + protocol.parse_airos_packet(malformed_packet, host_ip) + + assert error_message in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_discover_devices_generic_oserror(mock_datagram_endpoint): + """Test discovery handles a generic OSError during endpoint creation.""" + mock_transport, _ = mock_datagram_endpoint + + with ( + patch("asyncio.get_running_loop") as mock_get_loop, + pytest.raises(AirOSEndpointError) as excinfo, + ): + mock_loop = mock_get_loop.return_value + # Simulate an OSError that is NOT 'address in use' + mock_loop.create_datagram_endpoint = AsyncMock( + side_effect=OSError(13, "Permission denied") + ) + await async_discover_devices(timeout=1) + + assert "cannot_connect" in str(excinfo.value) + mock_transport.close.assert_not_called() diff --git a/tests/test_stations.py b/tests/test_stations.py index d7aa545..695da70 100644 --- a/tests/test_stations.py +++ b/tests/test_stations.py @@ -13,7 +13,7 @@ import aiohttp -async def _read_fixture(fixture: str = "ap-ptp"): +async def _read_fixture(fixture: str = "airos_loco5ac_ap-ptp"): """Read fixture file per device type.""" fixture_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") path = os.path.join(fixture_dir, f"{fixture}.json") @@ -26,9 +26,12 @@ async def _read_fixture(fixture: str = "ap-ptp"): pytest.fail(f"Invalid JSON in fixture file {path}: {e}") -@pytest.mark.parametrize("mode", ["ap-ptp", "sta-ptp"]) +@pytest.mark.parametrize( + "mode,fixture", + [("ap-ptp", "airos_loco5ac_ap-ptp"), ("sta-ptp", "airos_loco5ac_sta-ptp")], +) @pytest.mark.asyncio -async def test_ap_object(airos_device, base_url, mode): +async def test_ap_object(airos_device, base_url, mode, fixture): """Test device operation.""" cookie = SimpleCookie() cookie["session_id"] = "test-cookie" @@ -42,7 +45,7 @@ async def test_ap_object(airos_device, base_url, mode): 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) + fixture_data = await _read_fixture(fixture) mock_status_payload = fixture_data mock_status_response = MagicMock() mock_status_response.__aenter__.return_value = mock_status_response