Skip to content

Commit 6dbe600

Browse files
committed
Add discovery for consumption + adhere new exceptions
1 parent e39dd78 commit 6dbe600

File tree

1 file changed

+73
-25
lines changed

1 file changed

+73
-25
lines changed

airos/discovery.py

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
import struct
88
from typing import Any
99

10-
from .exceptions import AirosDiscoveryError, AirosEndpointError, AirosListenerError
10+
from .exceptions import AirOSDiscoveryError, AirOSEndpointError, AirOSListenerError
1111

1212
_LOGGER = logging.getLogger(__name__)
1313

1414
DISCOVERY_PORT: int = 10002
1515
BUFFER_SIZE: int = 1024
1616

1717

18-
class AirosDiscoveryProtocol(asyncio.DatagramProtocol):
18+
class AirOSDiscoveryProtocol(asyncio.DatagramProtocol):
1919
"""A UDP protocol implementation for discovering Ubiquiti airOS devices.
2020
2121
This class listens for UDP broadcast announcements from airOS devices
@@ -30,7 +30,7 @@ class AirosDiscoveryProtocol(asyncio.DatagramProtocol):
3030
"""
3131

3232
def __init__(self, callback: Callable[[dict[str, Any]], None]) -> None:
33-
"""Initialize AirosDiscoveryProtocol.
33+
"""Initialize AirOSDiscoveryProtocol.
3434
3535
Args:
3636
callback: An asynchronous function to call when a device is discovered.
@@ -46,7 +46,7 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
4646
sock: socket.socket = self.transport.get_extra_info("socket")
4747
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
4848
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
49-
log = f"Airos discovery listener (low-level) started on UDP port {DISCOVERY_PORT}."
49+
log = f"AirOS discovery listener (low-level) started on UDP port {DISCOVERY_PORT}."
5050
_LOGGER.debug(log)
5151

5252
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
@@ -60,30 +60,30 @@ 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) as err:
63+
except (AirOSEndpointError, AirOSListenerError) as err:
6464
# These are expected types of malformed packets. Log the specific error
65-
# and then re-raise as AirosDiscoveryError.
65+
# and then re-raise as AirOSDiscoveryError.
6666
log = f"Parsing failed for packet from {host_ip}: {err}"
6767
_LOGGER.exception(log)
68-
raise AirosDiscoveryError(f"Malformed packet from {host_ip}") from err
68+
raise AirOSDiscoveryError(f"Malformed packet from {host_ip}") from err
6969
except Exception as err:
7070
# General error during datagram reception (e.g., in callback itself)
71-
log = f"Error processing Airos discovery packet from {host_ip}. Data hex: {data.hex()}"
71+
log = f"Error processing AirOS discovery packet from {host_ip}. Data hex: {data.hex()}"
7272
_LOGGER.exception(log)
73-
raise AirosDiscoveryError from err
73+
raise AirOSDiscoveryError from err
7474

7575
def error_received(self, exc: Exception | None) -> None:
7676
"""Handle send or receive operation raises an OSError."""
7777
if exc:
78-
log = f"UDP error received in AirosDiscoveryProtocol: {exc}"
78+
log = f"UDP error received in AirOSDiscoveryProtocol: {exc}"
7979
_LOGGER.error(log)
8080

8181
def connection_lost(self, exc: Exception | None) -> None:
8282
"""Handle connection is lost or closed."""
83-
_LOGGER.debug("AirosDiscoveryProtocol connection lost.")
83+
_LOGGER.debug("AirOSDiscoveryProtocol connection lost.")
8484
if exc:
85-
_LOGGER.exception("AirosDiscoveryProtocol connection lost due to")
86-
raise AirosDiscoveryError from None
85+
_LOGGER.exception("AirOSDiscoveryProtocol connection lost due to")
86+
raise AirOSDiscoveryError from None
8787

8888
def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None:
8989
"""Parse a raw airOS discovery UDP packet.
@@ -117,12 +117,12 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
117117
if len(data) < 6:
118118
log = f"Packet too short for initial fixed header. Length: {len(data)}. Data: {data.hex()}"
119119
_LOGGER.debug(log)
120-
raise AirosEndpointError(f"Malformed packet: {log}")
120+
raise AirOSEndpointError(f"Malformed packet: {log}")
121121

122122
if data[0] != 0x01 or data[1] != 0x06:
123-
log = f"Packet does not start with expected Airos header (0x01 0x06). Actual: {data[0:2].hex()}"
123+
log = f"Packet does not start with expected AirOS header (0x01 0x06). Actual: {data[0:2].hex()}"
124124
_LOGGER.debug(log)
125-
raise AirosEndpointError(f"Malformed packet: {log}")
125+
raise AirOSEndpointError(f"Malformed packet: {log}")
126126

127127
offset: int = 6
128128

@@ -151,7 +151,7 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
151151
log = f"Truncated MAC address TLV (Type 0x06). Expected {expected_length}, got {len(data) - offset} bytes. Remaining: {data[offset:].hex()}"
152152
_LOGGER.warning(log)
153153
log = f"Malformed packet: {log}"
154-
raise AirosEndpointError(log)
154+
raise AirOSEndpointError(log)
155155

156156
elif tlv_type in [
157157
0x02,
@@ -169,7 +169,7 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
169169
log = f"Truncated TLV (Type {tlv_type:#x}), no 2-byte length field. Remaining: {data[offset:].hex()}"
170170
_LOGGER.warning(log)
171171
log = f"Malformed packet: {log}"
172-
raise AirosEndpointError(log)
172+
raise AirOSEndpointError(log)
173173

174174
tlv_length: int = struct.unpack_from(">H", data, offset)[0]
175175
offset += 2
@@ -182,7 +182,7 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
182182
log = f"Data from TLV start: {data[offset - 3 :].hex()}"
183183
_LOGGER.warning(log)
184184
log = f"Malformed packet: {log}"
185-
raise AirosEndpointError(log)
185+
raise AirOSEndpointError(log)
186186

187187
tlv_value: bytes = data[offset : offset + tlv_length]
188188

@@ -259,17 +259,65 @@ def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None
259259
log += f"Cannot determine length, stopping parsing. Remaining: {data[offset - 1 :].hex()}"
260260
_LOGGER.warning(log)
261261
log = f"Malformed packet: {log}"
262-
raise AirosEndpointError(log)
262+
raise AirOSEndpointError(log)
263263

264264
except (struct.error, IndexError) as err:
265-
log = f"Parsing error (struct/index) in AirosDiscoveryProtocol: {err} at offset {offset}. Remaining data: {data[offset:].hex()}"
265+
log = f"Parsing error (struct/index) in AirOSDiscoveryProtocol: {err} at offset {offset}. Remaining data: {data[offset:].hex()}"
266266
_LOGGER.debug(log)
267267
log = f"Malformed packet: {log}"
268-
raise AirosEndpointError(log) from err
269-
except AirosEndpointError: # Catch AirosEndpointError specifically, re-raise it
268+
raise AirOSEndpointError(log) from err
269+
except AirOSEndpointError: # Catch AirOSEndpointError specifically, re-raise it
270270
raise
271271
except Exception as err:
272-
_LOGGER.exception("Unexpected error during Airos packet parsing")
273-
raise AirosListenerError from err
272+
_LOGGER.exception("Unexpected error during AirOS packet parsing")
273+
raise AirOSListenerError from err
274274

275275
return parsed_info
276+
277+
278+
async def async_discover_devices(timeout: int) -> dict[str, dict[str, Any]]:
279+
"""Discover unconfigured airOS devices on the network for a given timeout.
280+
281+
This function sets up a listener, waits for a period, and returns
282+
all discovered devices.
283+
"""
284+
_LOGGER.debug("Starting AirOS device discovery for %s seconds", timeout)
285+
discovered_devices: dict[str, dict[str, Any]] = {}
286+
287+
def _async_airos_device_found(device_info: dict[str, Any]) -> None:
288+
"""Handle discovered device."""
289+
mac_address = device_info.get("mac_address")
290+
if mac_address:
291+
discovered_devices[mac_address] = device_info
292+
_LOGGER.debug("Discovered device: %s", device_info.get("hostname", mac_address))
293+
294+
transport: asyncio.DatagramTransport | None = None
295+
try:
296+
(
297+
transport,
298+
protocol,
299+
) = await asyncio.get_running_loop().create_datagram_endpoint(
300+
lambda: AirOSDiscoveryProtocol(_async_airos_device_found),
301+
local_addr=("0.0.0.0", DISCOVERY_PORT),
302+
)
303+
try:
304+
await asyncio.sleep(timeout)
305+
finally:
306+
if transport:
307+
_LOGGER.debug("Closing AirOS discovery listener")
308+
transport.close()
309+
except OSError as err:
310+
if err.errno == 98:
311+
_LOGGER.error("Address in use, another instance may be running.")
312+
raise AirOSEndpointError("address_in_use") from err
313+
_LOGGER.exception("Network endpoint error during discovery")
314+
raise AirOSEndpointError("cannot_connect") from err
315+
except asyncio.CancelledError as err:
316+
_LOGGER.warning("Discovery listener cancelled: %s", err)
317+
raise AirOSListenerError("cannot_connect") from err
318+
except Exception as err:
319+
_LOGGER.exception("An unexpected error occurred during discovery")
320+
raise AirOSListenerError("cannot_connect") from err
321+
322+
_LOGGER.debug("Discovery completed. Found %s devices.", len(discovered_devices))
323+
return discovered_devices

0 commit comments

Comments
 (0)