|
| 1 | +"""Discover Ubiquiti UISP airOS device broadcasts.""" |
| 2 | + |
| 3 | +import asyncio |
| 4 | +from collections.abc import Callable |
| 5 | +import logging |
| 6 | +import socket |
| 7 | +import struct |
| 8 | +from typing import Any |
| 9 | + |
| 10 | +from .exceptions import AirosDiscoveryError, AirosEndpointError, AirosListenerError |
| 11 | + |
| 12 | +_LOGGER = logging.getLogger(__name__) |
| 13 | + |
| 14 | +DISCOVERY_PORT: int = 10002 |
| 15 | +BUFFER_SIZE: int = 1024 |
| 16 | + |
| 17 | + |
| 18 | +class AirosDiscoveryProtocol(asyncio.DatagramProtocol): |
| 19 | + """A UDP protocol implementation for discovering Ubiquiti airOS devices. |
| 20 | +
|
| 21 | + This class listens for UDP broadcast announcements from airOS devices |
| 22 | + on a specific port (10002) and parses the proprietary packet format |
| 23 | + to extract device information. It acts as the low-level listener. |
| 24 | +
|
| 25 | + Attributes: |
| 26 | + callback: An asynchronous callable that will be invoked with |
| 27 | + the parsed device information upon discovery. |
| 28 | + transport: The UDP transport layer object, set once the connection is made. |
| 29 | +
|
| 30 | + """ |
| 31 | + |
| 32 | + def __init__(self, callback: Callable[[dict[str, Any]], None]) -> None: |
| 33 | + """Initialize AirosDiscoveryProtocol. |
| 34 | +
|
| 35 | + Args: |
| 36 | + callback: An asynchronous function to call when a device is discovered. |
| 37 | + It should accept a dictionary containing device information. |
| 38 | +
|
| 39 | + """ |
| 40 | + self.callback = callback |
| 41 | + self.transport: asyncio.DatagramTransport | None = None |
| 42 | + |
| 43 | + def connection_made(self, transport: asyncio.BaseTransport) -> None: |
| 44 | + """Set up the UDP socket for broadcasting and reusing the address.""" |
| 45 | + self.transport = transport # type: ignore[assignment] # transport is DatagramTransport |
| 46 | + sock: socket.socket = self.transport.get_extra_info("socket") |
| 47 | + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) |
| 48 | + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 49 | + log = f"Airos discovery listener (low-level) started on UDP port {DISCOVERY_PORT}." |
| 50 | + _LOGGER.debug(log) |
| 51 | + |
| 52 | + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: |
| 53 | + """Parse the received UDP packet and, if successful, schedules the callback. |
| 54 | +
|
| 55 | + Errors during parsing are logged internally by parse_airos_packet. |
| 56 | + """ |
| 57 | + host_ip: str = addr[0] |
| 58 | + try: |
| 59 | + parsed_data: dict[str, Any] | None = self.parse_airos_packet(data, host_ip) |
| 60 | + if parsed_data: |
| 61 | + # Schedule the user-provided callback, don't await to keep listener responsive |
| 62 | + asyncio.create_task(self.callback(parsed_data)) # noqa: RUF006 |
| 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 |
| 69 | + except Exception as err: |
| 70 | + # 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()}" |
| 72 | + _LOGGER.exception(log) |
| 73 | + raise AirosDiscoveryError from err |
| 74 | + |
| 75 | + def error_received(self, exc: Exception | None) -> None: |
| 76 | + """Handle send or receive operation raises an OSError.""" |
| 77 | + if exc: |
| 78 | + log = f"UDP error received in AirosDiscoveryProtocol: {exc}" |
| 79 | + _LOGGER.error(log) |
| 80 | + |
| 81 | + def connection_lost(self, exc: Exception | None) -> None: |
| 82 | + """Handle connection is lost or closed.""" |
| 83 | + _LOGGER.debug("AirosDiscoveryProtocol connection lost.") |
| 84 | + if exc: |
| 85 | + _LOGGER.exception("AirosDiscoveryProtocol connection lost due to") |
| 86 | + raise AirosDiscoveryError from None |
| 87 | + |
| 88 | + def parse_airos_packet(self, data: bytes, host_ip: str) -> dict[str, Any] | None: |
| 89 | + """Parse a raw airOS discovery UDP packet. |
| 90 | +
|
| 91 | + This method extracts various pieces of information from the proprietary |
| 92 | + Ubiquiti airOS discovery packet format, which includes a fixed header |
| 93 | + followed by a series of Type-Length-Value (TLV) entries. Different |
| 94 | + TLV types use different length encoding schemes (fixed, 1-byte, 2-byte). |
| 95 | +
|
| 96 | + Args: |
| 97 | + data: The raw byte data of the UDP packet payload. |
| 98 | + host_ip: The IP address of the sender, used as a fallback or initial IP. |
| 99 | +
|
| 100 | + Returns: |
| 101 | + A dictionary containing parsed device information if successful, |
| 102 | + otherwise None. Values will be None if not found or cannot be parsed. |
| 103 | +
|
| 104 | + """ |
| 105 | + parsed_info: dict[str, str | int | None] = { |
| 106 | + "ip_address": host_ip, |
| 107 | + "mac_address": None, |
| 108 | + "hostname": None, |
| 109 | + "model": None, |
| 110 | + "firmware_version": None, |
| 111 | + "uptime_seconds": None, |
| 112 | + "ssid": None, |
| 113 | + "full_model_name": None, |
| 114 | + } |
| 115 | + |
| 116 | + # --- Fixed Header (6 bytes) --- |
| 117 | + if len(data) < 6: |
| 118 | + log = f"Packet too short for initial fixed header. Length: {len(data)}. Data: {data.hex()}" |
| 119 | + _LOGGER.debug(log) |
| 120 | + raise AirosEndpointError(f"Malformed packet: {log}") |
| 121 | + |
| 122 | + 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()}" |
| 124 | + _LOGGER.debug(log) |
| 125 | + raise AirosEndpointError(f"Malformed packet: {log}") |
| 126 | + |
| 127 | + offset: int = 6 |
| 128 | + |
| 129 | + # --- Main TLV Parsing Loop --- |
| 130 | + try: |
| 131 | + while offset < len(data): |
| 132 | + if (len(data) - offset) < 1: |
| 133 | + log = f"Not enough bytes for next TLV type. Remaining: {data[offset:].hex()}" |
| 134 | + _LOGGER.debug(log) |
| 135 | + break |
| 136 | + |
| 137 | + tlv_type: int = data[offset] |
| 138 | + offset += 1 |
| 139 | + |
| 140 | + if tlv_type == 0x06: # Device MAC Address (fixed 6-byte value) |
| 141 | + expected_length: int = 6 |
| 142 | + if (len(data) - offset) >= expected_length: |
| 143 | + mac_bytes: bytes = data[offset : offset + expected_length] |
| 144 | + parsed_info["mac_address"] = ":".join( |
| 145 | + f"{b:02x}" for b in mac_bytes |
| 146 | + ).upper() |
| 147 | + offset += expected_length |
| 148 | + log = f"Parsed MAC from type 0x06: {parsed_info['mac_address']}" |
| 149 | + _LOGGER.debug(log) |
| 150 | + else: |
| 151 | + log = f"Truncated MAC address TLV (Type 0x06). Expected {expected_length}, got {len(data) - offset} bytes. Remaining: {data[offset:].hex()}" |
| 152 | + _LOGGER.warning(log) |
| 153 | + log = f"Malformed packet: {log}" |
| 154 | + raise AirosEndpointError(log) |
| 155 | + |
| 156 | + elif tlv_type in [ |
| 157 | + 0x02, |
| 158 | + 0x03, |
| 159 | + 0x0A, |
| 160 | + 0x0B, |
| 161 | + 0x0C, |
| 162 | + 0x0D, |
| 163 | + 0x0E, |
| 164 | + 0x10, |
| 165 | + 0x14, |
| 166 | + 0x18, |
| 167 | + ]: |
| 168 | + if (len(data) - offset) < 2: |
| 169 | + log = f"Truncated TLV (Type {tlv_type:#x}), no 2-byte length field. Remaining: {data[offset:].hex()}" |
| 170 | + _LOGGER.warning(log) |
| 171 | + log = f"Malformed packet: {log}" |
| 172 | + raise AirosEndpointError(log) |
| 173 | + |
| 174 | + tlv_length: int = struct.unpack_from(">H", data, offset)[0] |
| 175 | + offset += 2 |
| 176 | + |
| 177 | + if tlv_length > (len(data) - offset): |
| 178 | + log = f"TLV type {tlv_type:#x} length {tlv_length} exceeds remaining data " |
| 179 | + _LOGGER.warning(log) |
| 180 | + log = f"({len(data) - offset} bytes left). Packet malformed. " |
| 181 | + _LOGGER.warning(log) |
| 182 | + log = f"Data from TLV start: {data[offset - 3 :].hex()}" |
| 183 | + _LOGGER.warning(log) |
| 184 | + log = f"Malformed packet: {log}" |
| 185 | + raise AirosEndpointError(log) |
| 186 | + |
| 187 | + tlv_value: bytes = data[offset : offset + tlv_length] |
| 188 | + |
| 189 | + if tlv_type == 0x02: |
| 190 | + if tlv_length == 10: |
| 191 | + ip_bytes: bytes = tlv_value[6:10] |
| 192 | + parsed_info["ip_address"] = ".".join(map(str, ip_bytes)) |
| 193 | + log = f"Parsed IP from type 0x02 block: {parsed_info['ip_address']}" |
| 194 | + _LOGGER.debug(log) |
| 195 | + else: |
| 196 | + log = f"Unexpected length for 0x02 TLV (MAC+IP). Expected 10, got {tlv_length}. Value: {tlv_value.hex()}" |
| 197 | + _LOGGER.warning(log) |
| 198 | + |
| 199 | + elif tlv_type == 0x03: |
| 200 | + parsed_info["firmware_version"] = tlv_value.decode( |
| 201 | + "ascii", errors="ignore" |
| 202 | + ) |
| 203 | + log = f"Parsed Firmware: {parsed_info['firmware_version']}" |
| 204 | + _LOGGER.debug(log) |
| 205 | + |
| 206 | + elif tlv_type == 0x0A: |
| 207 | + if tlv_length == 4: |
| 208 | + parsed_info["uptime_seconds"] = struct.unpack( |
| 209 | + ">I", tlv_value |
| 210 | + )[0] |
| 211 | + log = f"Parsed Uptime: {parsed_info['uptime_seconds']}s" |
| 212 | + _LOGGER.debug(log) |
| 213 | + else: |
| 214 | + log = f"Unexpected length for Uptime (Type 0x0A): {tlv_length}. Value: {tlv_value.hex()}" |
| 215 | + _LOGGER.warning(log) |
| 216 | + |
| 217 | + elif tlv_type == 0x0B: |
| 218 | + parsed_info["hostname"] = tlv_value.decode( |
| 219 | + "utf-8", errors="ignore" |
| 220 | + ) |
| 221 | + log = f"Parsed Hostname: {parsed_info['hostname']}" |
| 222 | + _LOGGER.debug(log) |
| 223 | + |
| 224 | + elif tlv_type == 0x0C: |
| 225 | + parsed_info["model"] = tlv_value.decode( |
| 226 | + "ascii", errors="ignore" |
| 227 | + ) |
| 228 | + log = f"Parsed Model: {parsed_info['model']}" |
| 229 | + _LOGGER.debug(log) |
| 230 | + |
| 231 | + elif tlv_type == 0x0D: |
| 232 | + parsed_info["ssid"] = tlv_value.decode("utf-8", errors="ignore") |
| 233 | + log = f"Parsed SSID: {parsed_info['ssid']}" |
| 234 | + _LOGGER.debug(log) |
| 235 | + |
| 236 | + elif tlv_type == 0x14: |
| 237 | + parsed_info["full_model_name"] = tlv_value.decode( |
| 238 | + "utf-8", errors="ignore" |
| 239 | + ) |
| 240 | + log = ( |
| 241 | + f"Parsed Full Model Name: {parsed_info['full_model_name']}" |
| 242 | + ) |
| 243 | + _LOGGER.debug(log) |
| 244 | + |
| 245 | + elif tlv_type == 0x18: |
| 246 | + if tlv_length == 4 and tlv_value == b"\x00\x00\x00\x00": |
| 247 | + _LOGGER.debug("Detected end marker (Type 0x18).") |
| 248 | + else: |
| 249 | + log = f"Unhandled TLV type: {tlv_type:#x} with length {tlv_length}. Value: {tlv_value.hex()}" |
| 250 | + _LOGGER.debug(log) |
| 251 | + elif tlv_type in [0x0E, 0x10]: |
| 252 | + log = f"Unhandled TLV type: {tlv_type:#x} with length {tlv_length}. Value: {tlv_value.hex()}" |
| 253 | + _LOGGER.debug(log) |
| 254 | + |
| 255 | + offset += tlv_length |
| 256 | + |
| 257 | + else: |
| 258 | + 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()}" |
| 260 | + _LOGGER.warning(log) |
| 261 | + log = f"Malformed packet: {log}" |
| 262 | + raise AirosEndpointError(log) |
| 263 | + |
| 264 | + except (struct.error, IndexError) as err: |
| 265 | + log = f"Parsing error (struct/index) in AirosDiscoveryProtocol: {err} at offset {offset}. Remaining data: {data[offset:].hex()}" |
| 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 |
| 271 | + except Exception as err: |
| 272 | + _LOGGER.exception("Unexpected error during Airos packet parsing") |
| 273 | + raise AirosListenerError from err |
| 274 | + |
| 275 | + return parsed_info |
0 commit comments