Skip to content

Commit 6867740

Browse files
committed
Tests and improvements
1 parent 43e098c commit 6867740

File tree

5 files changed

+330
-14
lines changed

5 files changed

+330
-14
lines changed

.github/workflows/verify.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ jobs:
151151
run: |
152152
. venv/bin/activate
153153
coverage combine coverage*/.coverage*
154-
coverage report --fail-under=50
154+
coverage report --fail-under=85
155155
coverage xml
156156
- name: Upload coverage to Codecov
157157
uses: codecov/codecov-action@v5

airos/discovery.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,12 @@ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
6060
if parsed_data:
6161
# Schedule the user-provided callback, don't await to keep listener responsive
6262
asyncio.create_task(self.callback(parsed_data)) # noqa: RUF006
63-
except (AirosEndpointError, AirosListenerError):
64-
# Re-raise discovery-specific errors as-is
65-
raise
63+
except (AirosEndpointError, AirosListenerError) as err:
64+
# These are expected types of malformed packets. Log the specific error
65+
# and then re-raise as AirosDiscoveryError.
66+
log = f"Parsing failed for packet from {host_ip}: {err}"
67+
_LOGGER.exception(log)
68+
raise AirosDiscoveryError(f"Malformed packet from {host_ip}") from err
6669
except Exception as err:
6770
# General error during datagram reception (e.g., in callback itself)
6871
log = f"Error processing Airos discovery packet from {host_ip}. Data hex: {data.hex()}"
@@ -114,12 +117,12 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
114117
if len(data) < 6:
115118
log = f"Packet too short for initial fixed header. Length: {len(data)}. Data: {data.hex()}"
116119
_LOGGER.debug(log)
117-
return None
120+
raise AirosEndpointError(f"Malformed packet: {log}")
118121

119122
if data[0] != 0x01 or data[1] != 0x06:
120123
log = f"Packet does not start with expected Airos header (0x01 0x06). Actual: {data[0:2].hex()}"
121124
_LOGGER.debug(log)
122-
return None
125+
raise AirosEndpointError(f"Malformed packet: {log}")
123126

124127
offset: int = 6
125128

@@ -147,7 +150,8 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
147150
else:
148151
log = f"Truncated MAC address TLV (Type 0x06). Expected {expected_length}, got {len(data) - offset} bytes. Remaining: {data[offset:].hex()}"
149152
_LOGGER.warning(log)
150-
break
153+
log = f"Malformed packet: {log}"
154+
raise AirosEndpointError(log)
151155

152156
elif tlv_type in [
153157
0x02,
@@ -164,7 +168,8 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
164168
if (len(data) - offset) < 2:
165169
log = f"Truncated TLV (Type {tlv_type:#x}), no 2-byte length field. Remaining: {data[offset:].hex()}"
166170
_LOGGER.warning(log)
167-
break
171+
log = f"Malformed packet: {log}"
172+
raise AirosEndpointError(log)
168173

169174
tlv_length: int = struct.unpack_from(">H", data, offset)[0]
170175
offset += 2
@@ -176,7 +181,8 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
176181
_LOGGER.warning(log)
177182
log = f"Data from TLV start: {data[offset - 3 :].hex()}"
178183
_LOGGER.warning(log)
179-
break
184+
log = f"Malformed packet: {log}"
185+
raise AirosEndpointError(log)
180186

181187
tlv_value: bytes = data[offset : offset + tlv_length]
182188

@@ -250,15 +256,18 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
250256

251257
else:
252258
log = f"Unhandled TLV type: {tlv_type:#x} at offset {offset - 1}. "
259+
log += f"Cannot determine length, stopping parsing. Remaining: {data[offset - 1 :].hex()}"
253260
_LOGGER.warning(log)
254-
log = f"Cannot determine length, stopping parsing. Remaining: {data[offset - 1 :].hex()}"
255-
_LOGGER.warning(log)
256-
break
261+
log = f"Malformed packet: {log}"
262+
raise AirosEndpointError(log)
257263

258264
except (struct.error, IndexError) as err:
259265
log = f"Parsing error (struct/index) in AirosDiscoveryProtocol: {err} at offset {offset}. Remaining data: {data[offset:].hex()}"
260-
_LOGGER.warning(log)
261-
raise AirosEndpointError from err
266+
_LOGGER.debug(log)
267+
log = f"Malformed packet: {log}"
268+
raise AirosEndpointError(log) from err
269+
except AirosEndpointError: # Catch AirosEndpointError specifically, re-raise it
270+
raise
262271
except Exception as err:
263272
_LOGGER.exception("Unexpected error during Airos packet parsing")
264273
raise AirosListenerError from err
110 Bytes
Binary file not shown.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Generate mock discovery packet for testing."""
2+
3+
import logging
4+
import os
5+
import socket
6+
import struct
7+
8+
_LOGGER = logging.getLogger(__name__)
9+
10+
# Define the path to save the fixture
11+
fixture_dir = os.path.join(os.path.dirname(__file__), "../fixtures")
12+
os.makedirs(fixture_dir, exist_ok=True) # Ensure the directory exists
13+
fixture_path = os.path.join(fixture_dir, "airos_sta_discovery_packet.bin")
14+
15+
# Header: 0x01 0x06 (2 bytes) + 4 reserved bytes = 6 bytes
16+
HEADER = b"\x01\x06\x00\x00\x00\x00"
17+
18+
# --- Scrubbed Values ---
19+
SCRUBBED_MAC = "01:23:45:67:89:CD"
20+
SCRUBBED_MAC_BYTES = bytes.fromhex(SCRUBBED_MAC.replace(":", ""))
21+
SCRUBBED_IP = "192.168.1.3"
22+
SCRUBBED_IP_BYTES = socket.inet_aton(SCRUBBED_IP)
23+
SCRUBBED_HOSTNAME = "name"
24+
SCRUBBED_HOSTNAME_BYTES = SCRUBBED_HOSTNAME.encode("utf-8")
25+
26+
# --- Values from provided "schuur" JSON (not scrubbed) ---
27+
FIRMWARE_VERSION = "WA.V8.7.17"
28+
FIRMWARE_VERSION_BYTES = FIRMWARE_VERSION.encode("ascii")
29+
UPTIME_SECONDS = 265375
30+
MODEL = "NanoStation 5AC loco"
31+
MODEL_BYTES = MODEL.encode("ascii")
32+
SSID = "DemoSSID"
33+
SSID_BYTES = SSID.encode("utf-8")
34+
FULL_MODEL_NAME = (
35+
"NanoStation 5AC loco" # Using the same as Model, as is often the case
36+
)
37+
FULL_MODEL_NAME_BYTES = FULL_MODEL_NAME.encode("utf-8")
38+
39+
# TLV Type 0x06: MAC Address (fixed 6-byte value)
40+
TLV_MAC_TYPE = b"\x06"
41+
TLV_MAC = TLV_MAC_TYPE + SCRUBBED_MAC_BYTES
42+
43+
# TLV Type 0x02: MAC + IP Address (10 bytes value, with 2-byte length field)
44+
# Value contains first 6 bytes often MAC, last 4 bytes IP
45+
TLV_IP_TYPE = b"\x02"
46+
TLV_IP_VALUE = (
47+
SCRUBBED_MAC_BYTES + SCRUBBED_IP_BYTES
48+
) # 6 bytes MAC + 4 bytes IP = 10 bytes
49+
TLV_IP_LENGTH = len(TLV_IP_VALUE).to_bytes(2, "big")
50+
TLV_IP = TLV_IP_TYPE + TLV_IP_LENGTH + TLV_IP_VALUE
51+
52+
# TLV Type 0x03: Firmware Version (variable length string)
53+
TLV_FW_TYPE = b"\x03"
54+
TLV_FW_LENGTH = len(FIRMWARE_VERSION_BYTES).to_bytes(2, "big")
55+
TLV_FW = TLV_FW_TYPE + TLV_FW_LENGTH + FIRMWARE_VERSION_BYTES
56+
57+
# TLV Type 0x0A: Uptime (4-byte integer)
58+
TLV_UPTIME_TYPE = b"\x0a"
59+
TLV_UPTIME_VALUE = struct.pack(">I", UPTIME_SECONDS) # Unsigned int, big-endian
60+
TLV_UPTIME_LENGTH = len(TLV_UPTIME_VALUE).to_bytes(2, "big")
61+
TLV_UPTIME = TLV_UPTIME_TYPE + TLV_UPTIME_LENGTH + TLV_UPTIME_VALUE
62+
63+
# TLV Type 0x0B: Hostname (variable length string)
64+
TLV_HOSTNAME_TYPE = b"\x0b"
65+
TLV_HOSTNAME_LENGTH = len(SCRUBBED_HOSTNAME_BYTES).to_bytes(2, "big")
66+
TLV_HOSTNAME = TLV_HOSTNAME_TYPE + TLV_HOSTNAME_LENGTH + SCRUBBED_HOSTNAME_BYTES
67+
68+
# TLV Type 0x0C: Model (variable length string)
69+
TLV_MODEL_TYPE = b"\x0c"
70+
TLV_MODEL_LENGTH = len(MODEL_BYTES).to_bytes(2, "big")
71+
TLV_MODEL = TLV_MODEL_TYPE + TLV_MODEL_LENGTH + MODEL_BYTES
72+
73+
# TLV Type 0x0D: SSID (variable length string)
74+
TLV_SSID_TYPE = b"\x0d"
75+
TLV_SSID_LENGTH = len(SSID_BYTES).to_bytes(2, "big")
76+
TLV_SSID = TLV_SSID_TYPE + TLV_SSID_LENGTH + SSID_BYTES
77+
78+
# TLV Type 0x14: Full Model Name (variable length string)
79+
TLV_FULL_MODEL_TYPE = b"\x14"
80+
TLV_FULL_MODEL_LENGTH = len(FULL_MODEL_NAME_BYTES).to_bytes(2, "big")
81+
TLV_FULL_MODEL = TLV_FULL_MODEL_TYPE + TLV_FULL_MODEL_LENGTH + FULL_MODEL_NAME_BYTES
82+
83+
# Combine all parts
84+
FULL_PACKET = (
85+
HEADER
86+
+ TLV_MAC
87+
+ TLV_IP
88+
+ TLV_FW
89+
+ TLV_UPTIME
90+
+ TLV_HOSTNAME
91+
+ TLV_MODEL
92+
+ TLV_SSID
93+
+ TLV_FULL_MODEL
94+
)
95+
96+
# Write the actual binary file
97+
with open(fixture_path, "wb") as f:
98+
f.write(FULL_PACKET)
99+
100+
log = f"Generated discovery packet fixture at: {fixture_path}"
101+
log += f"Packet length: {len(FULL_PACKET)} bytes"
102+
log += f"Packet hex: {FULL_PACKET.hex()}"
103+
_LOGGER.info(log)

0 commit comments

Comments
 (0)