Skip to content

Commit e82cb88

Browse files
authored
Merge pull request #48 from CoMPaTech/improvements
New Features Enhanced privacy by redacting sensitive data (e.g., MAC and IP addresses) in logs and exceptions. Expanded device support with new device configurations and fixtures, including additional supported models. Improved robustness with optional fields for device data, addressing edge cases in device status reporting. Updated device discovery to allow configurable listening IP and port. Bug Fixes Improved error handling and logging for device status deserialization errors. Documentation Updated changelog and contribution guidelines to reflect new features, supported devices, and recent changes. Tests Added tests for redacted logging and enhanced coverage for new device types and error scenarios. Chores Updated project version to 0.2.6. Generated and added new fixture files for expanded device scenarios.
2 parents 09ba903 + 508c04f commit e82cb88

19 files changed

+3424
-41
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.2.6] - 2025-08-06
6+
7+
### Added
8+
9+
- Added redaction of data in exceptions when requesting `status()`
10+
- Additional settings in dataclass (HA Core Issue 150118)
11+
- Added 'likely' mocked fixture for above issue
12+
- Added additional devices (see [Contributing](CONTRIBUTE.md) for more information)
13+
14+
### Changed
15+
16+
- Changed name and kwargs for discovery function
17+
518
## [0.2.5] - 2025-08-05
619

720
### Added

CONTRIBUTE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ It would be very helpful if you would share your configuration data to this proj
77
We currently have data on
88

99
- Nanostation 5AC (LOCO5AC) - PTP - both AP and Station output of `/status.cgi` present (by @CoMPaTech)
10+
- Nanobeam 5AC - PTMP - Station (by @PlayFaster)
11+
- LiteAP GPS - PTMP - AP (by @PlayFaster)
1012

1113
## Secure your data
1214

airos/airos8.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import aiohttp
1111
from mashumaro.exceptions import InvalidFieldValue, MissingField
1212

13-
from .data import AirOS8Data as AirOSData
13+
from .data import AirOS8Data as AirOSData, redact_data_smart
1414
from .exceptions import (
1515
AirOSConnectionAuthenticationError,
1616
AirOSConnectionSetupError,
@@ -268,28 +268,44 @@ async def status(self) -> AirOSData:
268268
if response.status == 200:
269269
try:
270270
response_json = json.loads(response_text)
271-
try:
272-
adjusted_json = self.derived_data(response_json)
273-
airos_data = AirOSData.from_dict(adjusted_json)
274-
except (MissingField, InvalidFieldValue) as err:
275-
_LOGGER.exception("Failed to deserialize AirOS data")
276-
raise AirOSKeyDataMissingError from err
277-
278-
return airos_data
271+
adjusted_json = self.derived_data(response_json)
272+
airos_data = AirOSData.from_dict(adjusted_json)
273+
except InvalidFieldValue as err:
274+
# Log with .error() as this is a specific, known type of issue
275+
redacted_data = redact_data_smart(response_json)
276+
_LOGGER.error(
277+
"Failed to deserialize AirOS data due to an invalid field value: %s",
278+
redacted_data,
279+
)
280+
raise AirOSKeyDataMissingError from err
281+
except MissingField as err:
282+
# Log with .exception() for a full stack trace
283+
redacted_data = redact_data_smart(response_json)
284+
_LOGGER.exception(
285+
"Failed to deserialize AirOS data due to a missing field: %s",
286+
redacted_data,
287+
)
288+
raise AirOSKeyDataMissingError from err
289+
279290
except json.JSONDecodeError:
280291
_LOGGER.exception(
281292
"JSON Decode Error in authenticated status response"
282293
)
283294
raise AirOSDataMissingError from None
295+
296+
return airos_data
284297
else:
285-
log = f"Authenticated status.cgi failed: {response.status}. Response: {response_text}"
286-
_LOGGER.error(log)
287-
raise AirOSDeviceConnectionError from None
298+
_LOGGER.error(
299+
"Status API call failed with status %d: %s",
300+
response.status,
301+
response_text,
302+
)
303+
raise AirOSDeviceConnectionError
288304
except (
289305
aiohttp.ClientError,
290306
aiohttp.client_exceptions.ConnectionTimeoutError,
291307
) as err:
292-
_LOGGER.exception("Error during authenticated status.cgi call")
308+
_LOGGER.error("Status API call failed: %s", err)
293309
raise AirOSDeviceConnectionError from err
294310

295311
async def stakick(self, mac_address: str = None) -> bool:

airos/data.py

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,95 @@
22

33
from dataclasses import dataclass
44
from enum import Enum
5+
import ipaddress
56
import logging
7+
import re
68
from typing import Any
79

810
from mashumaro import DataClassDictMixin
911

1012
logger = logging.getLogger(__name__)
1113

14+
# Regex for a standard MAC address format (e.g., 01:23:45:67:89:AB)
15+
# This handles both colon and hyphen separators.
16+
MAC_ADDRESS_REGEX = re.compile(r"^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$")
17+
18+
# Regex for a MAC address mask (e.g., the redacted format 00:00:00:00:89:AB)
19+
MAC_ADDRESS_MASK_REGEX = re.compile(r"^(00:){4}[0-9a-fA-F]{2}[:-][0-9a-fA-F]{2}$")
20+
21+
22+
# Helper functions
23+
def is_mac_address(value: str) -> bool:
24+
"""Check if a string is a valid MAC address."""
25+
return bool(MAC_ADDRESS_REGEX.match(value))
26+
27+
28+
def is_mac_address_mask(value: str) -> bool:
29+
"""Check if a string is a valid MAC address mask (e.g., the redacted format)."""
30+
return bool(MAC_ADDRESS_MASK_REGEX.match(value))
31+
32+
33+
def is_ip_address(value: str) -> bool:
34+
"""Check if a string is a valid IPv4 or IPv6 address."""
35+
try:
36+
ipaddress.ip_address(value)
37+
return True
38+
except ValueError:
39+
return False
40+
41+
42+
def redact_data_smart(data: dict) -> dict:
43+
"""Recursively redacts sensitive keys in a dictionary."""
44+
sensitive_keys = {
45+
"hostname",
46+
"essid",
47+
"mac",
48+
"apmac",
49+
"hwaddr",
50+
"lastip",
51+
"ipaddr",
52+
"ip6addr",
53+
"device_id",
54+
"sys_id",
55+
"station_id",
56+
"platform",
57+
}
58+
59+
def _redact(d: dict):
60+
if not isinstance(d, dict):
61+
return d
62+
63+
redacted_d = {}
64+
for k, v in d.items():
65+
if k in sensitive_keys:
66+
if isinstance(v, str) and (is_mac_address(v) or is_mac_address_mask(v)):
67+
# Redact only the first 6 hex characters of a MAC address
68+
redacted_d[k] = "00:11:22:33:" + v.replace("-", ":").upper()[-5:]
69+
elif isinstance(v, str) and is_ip_address(v):
70+
# Redact to a dummy local IP address
71+
redacted_d[k] = "127.0.0.3"
72+
elif isinstance(v, list) and all(
73+
isinstance(i, str) and is_ip_address(i) for i in v
74+
):
75+
# Redact list of IPs to a dummy list
76+
redacted_d[k] = ["127.0.0.3"]
77+
else:
78+
redacted_d[k] = "REDACTED"
79+
elif isinstance(v, dict):
80+
redacted_d[k] = _redact(v)
81+
elif isinstance(v, list):
82+
redacted_d[k] = [
83+
_redact(item) if isinstance(item, dict) else item for item in v
84+
]
85+
else:
86+
redacted_d[k] = v
87+
return redacted_d
88+
89+
return _redact(data)
90+
91+
92+
# Data class start
93+
1294

1395
def _check_and_log_unknown_enum_value(
1496
data_dict: dict[str, Any],
@@ -152,6 +234,7 @@ class Polling:
152234
fixed_frame: bool
153235
gps_sync: bool
154236
ff_cap_rep: bool
237+
flex_mode: int | None = None # Not present in all devices
155238

156239

157240
@dataclass
@@ -207,9 +290,14 @@ class EthList:
207290
class GPSData:
208291
"""Leaf definition."""
209292

210-
lat: str
211-
lon: str
212-
fix: int
293+
lat: float | None = None
294+
lon: float | None = None
295+
fix: int | None = None
296+
sats: int | None = None # LiteAP GPS
297+
dim: int | None = None # LiteAP GPS
298+
dop: float | None = None # LiteAP GPS
299+
alt: float | None = None # LiteAP GPS
300+
time_synced: int | None = None # LiteAP GPS
213301

214302

215303
@dataclass
@@ -235,7 +323,6 @@ class Remote:
235323
totalram: int
236324
freeram: int
237325
netrole: str
238-
mode: WirelessMode
239326
sys_id: str
240327
tx_throughput: int
241328
rx_throughput: int
@@ -254,20 +341,21 @@ class Remote:
254341
rx_bytes: int
255342
antenna_gain: int
256343
cable_loss: int
257-
height: int
258344
ethlist: list[EthList]
259345
ipaddr: list[str]
260-
ip6addr: list[str]
261346
gps: GPSData
262347
oob: bool
263348
unms: UnmsStatus
264349
airview: int
265350
service: ServiceTime
351+
mode: WirelessMode | None = None # Investigate why remotes can have no mode set
352+
ip6addr: list[str] | None = None # For v4 only devices
353+
height: int | None = None
266354

267355
@classmethod
268356
def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]:
269357
"""Pre-deserialize hook for Wireless."""
270-
_check_and_log_unknown_enum_value(d, "mode", WirelessMode, "Wireless", "mode")
358+
_check_and_log_unknown_enum_value(d, "mode", WirelessMode, "Remote", "mode")
271359
return d
272360

273361

@@ -329,7 +417,6 @@ class Wireless:
329417
"""Leaf definition."""
330418

331419
essid: str
332-
mode: WirelessMode
333420
ieeemode: IeeeMode
334421
band: int
335422
compat_11n: int
@@ -362,6 +449,7 @@ class Wireless:
362449
count: int
363450
sta: list[Station]
364451
sta_disconnected: list[Disconnected]
452+
mode: WirelessMode | None = None # Investigate further (see WirelessMode in Remote)
365453

366454
@classmethod
367455
def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]:

airos/discovery.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,9 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
276276
return parsed_info
277277

278278

279-
async def async_discover_devices(timeout: int) -> dict[str, dict[str, Any]]:
279+
async def airos_discover_devices(
280+
timeout: int = 30, listen_ip: str = "0.0.0.0", port: int = DISCOVERY_PORT
281+
) -> dict[str, dict[str, Any]]:
280282
"""Discover unconfigured airOS devices on the network for a given timeout.
281283
282284
This function sets up a listener, waits for a period, and returns
@@ -301,7 +303,7 @@ async def _async_airos_device_found(device_info: dict[str, Any]) -> None:
301303
protocol,
302304
) = await asyncio.get_running_loop().create_datagram_endpoint(
303305
lambda: AirOSDiscoveryProtocol(_async_airos_device_found),
304-
local_addr=("0.0.0.0", DISCOVERY_PORT),
306+
local_addr=(listen_ip, port),
305307
)
306308
try:
307309
await asyncio.sleep(timeout)

0 commit comments

Comments
 (0)