77import struct
88from 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
1414DISCOVERY_PORT : int = 10002
1515BUFFER_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