lifx-async 4.7.5__py3-none-any.whl → 4.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ """DNS wire format parser for mDNS discovery.
2
+
3
+ This module provides minimal DNS parsing for mDNS service discovery,
4
+ supporting PTR, SRV, A, and TXT record types.
5
+
6
+ Uses only Python stdlib (struct, socket).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import socket
12
+ import struct
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ # DNS record types
17
+ DNS_TYPE_A = 1
18
+ DNS_TYPE_PTR = 12
19
+ DNS_TYPE_TXT = 16
20
+ DNS_TYPE_AAAA = 28
21
+ DNS_TYPE_SRV = 33
22
+
23
+ # DNS classes
24
+ DNS_CLASS_IN = 1
25
+ DNS_CLASS_UNIQUE = 0x8001 # Cache flush bit set
26
+
27
+ # Type names for display
28
+ _TYPE_NAMES = {
29
+ DNS_TYPE_A: "A",
30
+ DNS_TYPE_PTR: "PTR",
31
+ DNS_TYPE_TXT: "TXT",
32
+ DNS_TYPE_AAAA: "AAAA",
33
+ DNS_TYPE_SRV: "SRV",
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class DnsHeader:
39
+ """DNS message header (12 bytes).
40
+
41
+ Attributes:
42
+ id: Transaction ID (0 for mDNS)
43
+ flags: DNS flags
44
+ qd_count: Question count
45
+ an_count: Answer count
46
+ ns_count: Authority count
47
+ ar_count: Additional count
48
+ """
49
+
50
+ id: int
51
+ flags: int
52
+ qd_count: int
53
+ an_count: int
54
+ ns_count: int
55
+ ar_count: int
56
+
57
+ @property
58
+ def is_response(self) -> bool:
59
+ """Check if this is a response (QR bit set)."""
60
+ return bool(self.flags & 0x8000)
61
+
62
+ @classmethod
63
+ def parse(cls, data: bytes) -> DnsHeader:
64
+ """Parse a DNS header from bytes.
65
+
66
+ Args:
67
+ data: At least 12 bytes of DNS header data
68
+
69
+ Returns:
70
+ Parsed DnsHeader
71
+
72
+ Raises:
73
+ ValueError: If data is too short
74
+ """
75
+ if len(data) < 12:
76
+ raise ValueError(f"DNS header too short: {len(data)} bytes")
77
+ id_, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
78
+ return cls(id_, flags, qd, an, ns, ar)
79
+
80
+
81
+ @dataclass
82
+ class SrvData:
83
+ """Parsed SRV record data.
84
+
85
+ Attributes:
86
+ priority: Service priority
87
+ weight: Service weight
88
+ port: Service port
89
+ target: Target hostname
90
+ """
91
+
92
+ priority: int
93
+ weight: int
94
+ port: int
95
+ target: str
96
+
97
+
98
+ @dataclass
99
+ class TxtData:
100
+ """Parsed TXT record data.
101
+
102
+ Attributes:
103
+ strings: Raw TXT strings
104
+ pairs: Key-value pairs parsed from strings containing '='
105
+ """
106
+
107
+ strings: list[str] = field(default_factory=list)
108
+ pairs: dict[str, str] = field(default_factory=dict)
109
+
110
+
111
+ @dataclass
112
+ class DnsResourceRecord:
113
+ """DNS resource record.
114
+
115
+ Attributes:
116
+ name: Record name
117
+ rtype: Record type (A, PTR, TXT, SRV, etc.)
118
+ rclass: Record class (usually IN)
119
+ ttl: Time to live in seconds
120
+ rdata: Raw record data bytes
121
+ parsed_data: Parsed record data (type varies by rtype)
122
+ """
123
+
124
+ name: str
125
+ rtype: int
126
+ rclass: int
127
+ ttl: int
128
+ rdata: bytes
129
+ parsed_data: Any = None
130
+
131
+ @property
132
+ def type_name(self) -> str:
133
+ """Get human-readable record type name."""
134
+ return _TYPE_NAMES.get(self.rtype, f"TYPE{self.rtype}")
135
+
136
+ @property
137
+ def cache_flush(self) -> bool:
138
+ """Check if cache flush bit is set (mDNS unique)."""
139
+ return bool(self.rclass & 0x8000)
140
+
141
+
142
+ @dataclass
143
+ class ParsedDnsResponse:
144
+ """Parsed DNS response message.
145
+
146
+ Attributes:
147
+ header: DNS header
148
+ records: All resource records (answers + authority + additional)
149
+ """
150
+
151
+ header: DnsHeader
152
+ records: list[DnsResourceRecord]
153
+
154
+
155
+ def parse_name(data: bytes, offset: int) -> tuple[str, int]:
156
+ """Parse a DNS name with compression pointer support.
157
+
158
+ DNS names use length-prefixed labels, with 0xC0 prefix indicating
159
+ compression pointers to earlier positions in the message.
160
+
161
+ Args:
162
+ data: Complete DNS message bytes
163
+ offset: Starting offset in data
164
+
165
+ Returns:
166
+ Tuple of (parsed name, new offset after the name)
167
+
168
+ Raises:
169
+ ValueError: If name parsing fails
170
+ """
171
+ labels: list[str] = []
172
+ original_offset = offset
173
+ jumped = False
174
+ max_jumps = 10 # Prevent infinite loops
175
+ jumps = 0
176
+
177
+ while True:
178
+ if offset >= len(data):
179
+ raise ValueError(f"DNS name parsing ran off end of data at offset {offset}")
180
+
181
+ length = data[offset]
182
+
183
+ # Check for compression pointer (top 2 bits set)
184
+ if (length & 0xC0) == 0xC0:
185
+ if offset + 1 >= len(data):
186
+ raise ValueError("Compression pointer incomplete")
187
+ # Pointer to earlier in message
188
+ pointer = ((length & 0x3F) << 8) | data[offset + 1]
189
+ if not jumped:
190
+ original_offset = offset + 2
191
+ jumped = True
192
+ offset = pointer
193
+ jumps += 1
194
+ if jumps > max_jumps:
195
+ raise ValueError("Too many compression pointer jumps")
196
+ continue
197
+
198
+ offset += 1
199
+
200
+ if length == 0:
201
+ # End of name
202
+ break
203
+
204
+ if offset + length > len(data):
205
+ raise ValueError(
206
+ f"Label extends beyond data: offset={offset}, length={length}"
207
+ )
208
+
209
+ label = data[offset : offset + length].decode("utf-8", errors="replace")
210
+ labels.append(label)
211
+ offset += length
212
+
213
+ name = ".".join(labels) if labels else "."
214
+ final_offset = original_offset if jumped else offset
215
+
216
+ return name, final_offset
217
+
218
+
219
+ def parse_txt_record(rdata: bytes) -> TxtData:
220
+ """Parse TXT record data.
221
+
222
+ TXT records contain one or more length-prefixed strings.
223
+ LIFX uses key=value format in these strings.
224
+
225
+ Args:
226
+ rdata: TXT record data bytes
227
+
228
+ Returns:
229
+ Parsed TxtData with strings and key-value pairs
230
+ """
231
+ txt_data = TxtData()
232
+ offset = 0
233
+
234
+ while offset < len(rdata):
235
+ str_len = rdata[offset]
236
+ offset += 1
237
+ if offset + str_len > len(rdata):
238
+ break
239
+ txt_str = rdata[offset : offset + str_len].decode("utf-8", errors="replace")
240
+ txt_data.strings.append(txt_str)
241
+ # Try to parse as key=value
242
+ if "=" in txt_str:
243
+ key, _, value = txt_str.partition("=")
244
+ txt_data.pairs[key] = value
245
+ offset += str_len
246
+
247
+ return txt_data
248
+
249
+
250
+ def _parse_resource_record(data: bytes, offset: int) -> tuple[DnsResourceRecord, int]:
251
+ """Parse a single DNS resource record.
252
+
253
+ Args:
254
+ data: Complete DNS message bytes
255
+ offset: Starting offset of the record
256
+
257
+ Returns:
258
+ Tuple of (parsed record, new offset after the record)
259
+
260
+ Raises:
261
+ ValueError: If record parsing fails
262
+ """
263
+ name, offset = parse_name(data, offset)
264
+
265
+ if offset + 10 > len(data):
266
+ raise ValueError(f"Resource record header incomplete at offset {offset}")
267
+
268
+ rtype, rclass, ttl, rdlength = struct.unpack("!HHIH", data[offset : offset + 10])
269
+ offset += 10
270
+
271
+ if offset + rdlength > len(data):
272
+ available = len(data) - offset
273
+ raise ValueError(
274
+ f"Resource record data incomplete: need {rdlength}, have {available}"
275
+ )
276
+
277
+ rdata = data[offset : offset + rdlength]
278
+ offset += rdlength
279
+
280
+ # Parse specific record types
281
+ parsed_data: Any = None
282
+
283
+ if rtype == DNS_TYPE_A and rdlength == 4:
284
+ parsed_data = socket.inet_ntoa(rdata)
285
+
286
+ elif rtype == DNS_TYPE_AAAA and rdlength == 16:
287
+ parsed_data = socket.inet_ntop(socket.AF_INET6, rdata)
288
+
289
+ elif rtype == DNS_TYPE_PTR:
290
+ parsed_data, _ = parse_name(data, offset - rdlength)
291
+
292
+ elif rtype == DNS_TYPE_SRV and rdlength >= 6:
293
+ priority, weight, port = struct.unpack("!HHH", rdata[:6])
294
+ target, _ = parse_name(data, offset - rdlength + 6)
295
+ parsed_data = SrvData(priority, weight, port, target)
296
+
297
+ elif rtype == DNS_TYPE_TXT:
298
+ parsed_data = parse_txt_record(rdata)
299
+
300
+ return DnsResourceRecord(name, rtype, rclass, ttl, rdata, parsed_data), offset
301
+
302
+
303
+ def parse_dns_response(data: bytes) -> ParsedDnsResponse:
304
+ """Parse a complete DNS response message.
305
+
306
+ Args:
307
+ data: Complete DNS message bytes
308
+
309
+ Returns:
310
+ ParsedDnsResponse containing header and all records
311
+
312
+ Raises:
313
+ ValueError: If message parsing fails
314
+ """
315
+ header = DnsHeader.parse(data)
316
+ offset = 12
317
+ records: list[DnsResourceRecord] = []
318
+
319
+ # Skip questions (we don't need them for responses)
320
+ for _ in range(header.qd_count):
321
+ _, offset = parse_name(data, offset)
322
+ offset += 4 # QTYPE + QCLASS
323
+
324
+ # Parse all resource records (answers, authority, additional)
325
+ total_records = header.an_count + header.ns_count + header.ar_count
326
+ for _ in range(total_records):
327
+ record, offset = _parse_resource_record(data, offset)
328
+ records.append(record)
329
+
330
+ return ParsedDnsResponse(header, records)
331
+
332
+
333
+ def build_ptr_query(service: str) -> bytes:
334
+ """Build an mDNS PTR query for a service type.
335
+
336
+ Args:
337
+ service: Service name (e.g., "_lifx._udp.local")
338
+
339
+ Returns:
340
+ DNS query packet bytes ready to send
341
+
342
+ Example:
343
+ >>> query = build_ptr_query("_lifx._udp.local")
344
+ >>> # Send to 224.0.0.251:5353
345
+ """
346
+ # Header: ID=0 (mDNS), standard query, 1 question
347
+ header = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0)
348
+
349
+ # Question: service name, PTR type, IN class
350
+ question = b""
351
+ for label in service.split("."):
352
+ question += bytes([len(label)]) + label.encode("utf-8")
353
+ question += b"\x00" # Root label
354
+ question += struct.pack("!HH", DNS_TYPE_PTR, DNS_CLASS_IN)
355
+
356
+ return header + question
@@ -0,0 +1,313 @@
1
+ """mDNS transport for multicast UDP communication.
2
+
3
+ This module provides a UDP transport specifically for mDNS queries,
4
+ with multicast group joining and appropriate socket configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import socket
12
+ import struct
13
+ from asyncio import DatagramTransport
14
+ from typing import TYPE_CHECKING
15
+
16
+ from lifx.const import MDNS_ADDRESS, MDNS_PORT
17
+ from lifx.exceptions import LifxNetworkError, LifxTimeoutError
18
+
19
+ if TYPE_CHECKING:
20
+ pass
21
+
22
+ _LOGGER = logging.getLogger(__name__)
23
+
24
+
25
+ class _MdnsProtocol(asyncio.DatagramProtocol):
26
+ """Asyncio protocol for mDNS UDP communication."""
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize the protocol."""
30
+ self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
31
+ self._transport: DatagramTransport | None = None
32
+
33
+ def connection_made(self, transport: DatagramTransport) -> None: # type: ignore[override]
34
+ """Called when connection is established."""
35
+ self._transport = transport
36
+
37
+ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
38
+ """Called when a datagram is received."""
39
+ self.queue.put_nowait((data, addr))
40
+
41
+ def error_received(self, exc: Exception) -> None:
42
+ """Called when an error is received."""
43
+ _LOGGER.debug(
44
+ {
45
+ "class": "_MdnsProtocol",
46
+ "method": "error_received",
47
+ "action": "error",
48
+ "error": str(exc),
49
+ }
50
+ )
51
+
52
+ def connection_lost(self, exc: Exception | None) -> None:
53
+ """Called when connection is lost."""
54
+ _LOGGER.debug(
55
+ {
56
+ "class": "_MdnsProtocol",
57
+ "method": "connection_lost",
58
+ "action": "lost",
59
+ "error": str(exc) if exc else None,
60
+ }
61
+ )
62
+
63
+
64
+ class MdnsTransport:
65
+ """UDP transport for mDNS multicast communication.
66
+
67
+ This transport is specifically designed for mDNS queries and responses,
68
+ with support for multicast group membership and appropriate socket options.
69
+
70
+ Example:
71
+ >>> async with MdnsTransport() as transport:
72
+ ... await transport.send(query, (MDNS_ADDRESS, MDNS_PORT))
73
+ ... data, addr = await transport.receive(timeout=5.0)
74
+ """
75
+
76
+ def __init__(self) -> None:
77
+ """Initialize mDNS transport."""
78
+ self._protocol: _MdnsProtocol | None = None
79
+ self._transport: DatagramTransport | None = None
80
+ self._socket: socket.socket | None = None
81
+
82
+ async def __aenter__(self) -> MdnsTransport:
83
+ """Enter async context manager."""
84
+ await self.open()
85
+ return self
86
+
87
+ async def __aexit__(self, *args: object) -> None:
88
+ """Exit async context manager."""
89
+ await self.close()
90
+
91
+ async def open(self) -> None:
92
+ """Open the mDNS socket with multicast configuration.
93
+
94
+ Creates a UDP socket, configures it for mDNS multicast,
95
+ and joins the mDNS multicast group.
96
+
97
+ Raises:
98
+ LifxNetworkError: If socket creation or configuration fails
99
+ """
100
+ if self._protocol is not None:
101
+ _LOGGER.debug(
102
+ {
103
+ "class": "MdnsTransport",
104
+ "method": "open",
105
+ "action": "already_open",
106
+ }
107
+ )
108
+ return
109
+
110
+ try:
111
+ loop = asyncio.get_running_loop()
112
+
113
+ # Create and configure socket manually for multicast
114
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
115
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
116
+
117
+ # Try to set SO_REUSEPORT if available (Linux/macOS)
118
+ try:
119
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
120
+ except (AttributeError, OSError):
121
+ pass
122
+
123
+ # Bind to mDNS port (or ephemeral if busy)
124
+ try:
125
+ sock.bind(("", MDNS_PORT))
126
+ _LOGGER.debug(
127
+ {
128
+ "class": "MdnsTransport",
129
+ "method": "open",
130
+ "action": "bound_to_mdns_port",
131
+ "port": MDNS_PORT,
132
+ }
133
+ )
134
+ except OSError as e:
135
+ _LOGGER.debug(
136
+ {
137
+ "class": "MdnsTransport",
138
+ "method": "open",
139
+ "action": "mdns_port_busy",
140
+ "error": str(e),
141
+ }
142
+ )
143
+ # Fall back to ephemeral port
144
+ sock.bind(("", 0))
145
+ _LOGGER.debug(
146
+ {
147
+ "class": "MdnsTransport",
148
+ "method": "open",
149
+ "action": "bound_to_ephemeral_port",
150
+ "port": sock.getsockname()[1],
151
+ }
152
+ )
153
+
154
+ # Join mDNS multicast group
155
+ mreq = struct.pack("4sl", socket.inet_aton(MDNS_ADDRESS), socket.INADDR_ANY)
156
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
157
+ _LOGGER.debug(
158
+ {
159
+ "class": "MdnsTransport",
160
+ "method": "open",
161
+ "action": "joined_multicast_group",
162
+ "group": MDNS_ADDRESS,
163
+ }
164
+ )
165
+
166
+ # Set multicast TTL (1 for link-local)
167
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
168
+
169
+ # Make socket non-blocking
170
+ sock.setblocking(False)
171
+ self._socket = sock
172
+
173
+ # Create protocol
174
+ protocol = _MdnsProtocol()
175
+ self._protocol = protocol
176
+
177
+ # Create datagram endpoint using our configured socket
178
+ self._transport, _ = await loop.create_datagram_endpoint(
179
+ lambda: protocol,
180
+ sock=sock,
181
+ )
182
+
183
+ _LOGGER.debug(
184
+ {
185
+ "class": "MdnsTransport",
186
+ "method": "open",
187
+ "action": "opened",
188
+ }
189
+ )
190
+
191
+ except OSError as e:
192
+ _LOGGER.debug(
193
+ {
194
+ "class": "MdnsTransport",
195
+ "method": "open",
196
+ "action": "failed",
197
+ "error": str(e),
198
+ }
199
+ )
200
+ raise LifxNetworkError(f"Failed to open mDNS socket: {e}") from e
201
+
202
+ async def send(self, data: bytes, address: tuple[str, int] | None = None) -> None:
203
+ """Send data to mDNS multicast address.
204
+
205
+ Args:
206
+ data: Bytes to send
207
+ address: Target address (defaults to mDNS multicast address)
208
+
209
+ Raises:
210
+ LifxNetworkError: If socket is not open or send fails
211
+ """
212
+ if self._transport is None or self._protocol is None:
213
+ raise LifxNetworkError("Socket not open")
214
+
215
+ if address is None:
216
+ address = (MDNS_ADDRESS, MDNS_PORT)
217
+
218
+ try:
219
+ self._transport.sendto(data, address)
220
+ _LOGGER.debug(
221
+ {
222
+ "class": "MdnsTransport",
223
+ "method": "send",
224
+ "action": "sent",
225
+ "size": len(data),
226
+ "destination": address,
227
+ }
228
+ )
229
+ except OSError as e:
230
+ _LOGGER.debug(
231
+ {
232
+ "class": "MdnsTransport",
233
+ "method": "send",
234
+ "action": "failed",
235
+ "destination": address,
236
+ "error": str(e),
237
+ }
238
+ )
239
+ raise LifxNetworkError(f"Failed to send mDNS data: {e}") from e
240
+
241
+ async def receive(self, timeout: float = 5.0) -> tuple[bytes, tuple[str, int]]:
242
+ """Receive data from socket.
243
+
244
+ Args:
245
+ timeout: Timeout in seconds
246
+
247
+ Returns:
248
+ Tuple of (data, address) where address is (host, port)
249
+
250
+ Raises:
251
+ LifxTimeoutError: If no data received within timeout
252
+ LifxNetworkError: If socket is not open or receive fails
253
+ """
254
+ if self._protocol is None:
255
+ raise LifxNetworkError("Socket not open")
256
+
257
+ try:
258
+ async with asyncio.timeout(timeout):
259
+ data, addr = await self._protocol.queue.get()
260
+ return data, addr
261
+ except TimeoutError as e:
262
+ raise LifxTimeoutError(f"No mDNS data received within {timeout}s") from e
263
+ except OSError as e:
264
+ _LOGGER.debug(
265
+ {
266
+ "class": "MdnsTransport",
267
+ "method": "receive",
268
+ "action": "failed",
269
+ "error": str(e),
270
+ }
271
+ )
272
+ raise LifxNetworkError(f"Failed to receive mDNS data: {e}") from e
273
+
274
+ async def close(self) -> None:
275
+ """Close the mDNS socket."""
276
+ if self._transport is not None:
277
+ _LOGGER.debug(
278
+ {
279
+ "class": "MdnsTransport",
280
+ "method": "close",
281
+ "action": "closing",
282
+ }
283
+ )
284
+
285
+ # Leave multicast group
286
+ if self._socket is not None:
287
+ try:
288
+ mreq = struct.pack(
289
+ "4sl", socket.inet_aton(MDNS_ADDRESS), socket.INADDR_ANY
290
+ )
291
+ self._socket.setsockopt(
292
+ socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, mreq
293
+ )
294
+ except OSError:
295
+ pass # Ignore errors when leaving group
296
+
297
+ self._transport.close()
298
+ self._transport = None
299
+ self._protocol = None
300
+ self._socket = None
301
+
302
+ _LOGGER.debug(
303
+ {
304
+ "class": "MdnsTransport",
305
+ "method": "close",
306
+ "action": "closed",
307
+ }
308
+ )
309
+
310
+ @property
311
+ def is_open(self) -> bool:
312
+ """Check if socket is open."""
313
+ return self._protocol is not None
@@ -0,0 +1,35 @@
1
+ """Type definitions for mDNS discovery.
2
+
3
+ This module defines the data structures used for mDNS service discovery.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class LifxServiceRecord:
11
+ """Information about a LIFX device discovered via mDNS.
12
+
13
+ Attributes:
14
+ serial: Device serial number as 12-digit hex string (e.g., "d073d5123456")
15
+ ip: Device IP address
16
+ port: Device UDP port (typically 56700)
17
+ product_id: Product ID from TXT record 'p' field
18
+ firmware: Firmware version from TXT record 'fw' field
19
+ """
20
+
21
+ serial: str
22
+ ip: str
23
+ port: int
24
+ product_id: int
25
+ firmware: str
26
+
27
+ def __hash__(self) -> int:
28
+ """Hash based on serial number for deduplication."""
29
+ return hash(self.serial)
30
+
31
+ def __eq__(self, other: object) -> bool:
32
+ """Equality based on serial number."""
33
+ if not isinstance(other, LifxServiceRecord):
34
+ return False
35
+ return self.serial == other.serial