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.
lifx/__init__.py CHANGED
@@ -10,6 +10,7 @@ from importlib.metadata import version as get_version
10
10
  from lifx.api import (
11
11
  DeviceGroup,
12
12
  discover,
13
+ discover_mdns,
13
14
  find_by_ip,
14
15
  find_by_label,
15
16
  find_by_serial,
@@ -48,6 +49,7 @@ from lifx.exceptions import (
48
49
  LifxUnsupportedCommandError,
49
50
  )
50
51
  from lifx.network.discovery import DiscoveredDevice, discover_devices
52
+ from lifx.network.mdns import LifxServiceRecord, discover_lifx_services
51
53
  from lifx.products import ProductCapability, ProductInfo, ProductRegistry
52
54
  from lifx.protocol.protocol_types import (
53
55
  Direction,
@@ -100,12 +102,16 @@ __all__ = [
100
102
  # High-level API
101
103
  "DeviceGroup",
102
104
  "discover",
105
+ "discover_mdns",
103
106
  "find_by_serial",
104
107
  "find_by_label",
105
108
  "find_by_ip",
106
109
  # Discovery (low-level)
107
110
  "discover_devices",
108
111
  "DiscoveredDevice",
112
+ # mDNS Discovery (low-level)
113
+ "discover_lifx_services",
114
+ "LifxServiceRecord",
109
115
  # Products
110
116
  "ProductInfo",
111
117
  "ProductRegistry",
lifx/api.py CHANGED
@@ -803,6 +803,59 @@ async def discover(
803
803
  yield device
804
804
 
805
805
 
806
+ async def discover_mdns(
807
+ timeout: float = DISCOVERY_TIMEOUT,
808
+ max_response_time: float = MAX_RESPONSE_TIME,
809
+ idle_timeout_multiplier: float = IDLE_TIMEOUT_MULTIPLIER,
810
+ device_timeout: float = DEFAULT_REQUEST_TIMEOUT,
811
+ max_retries: int = DEFAULT_MAX_RETRIES,
812
+ ) -> AsyncGenerator[Light, None]:
813
+ """Discover LIFX devices via mDNS and yield them as they are found.
814
+
815
+ Uses mDNS/DNS-SD discovery with the _lifx._udp.local service type.
816
+ This method is faster than broadcast discovery as device type information
817
+ is included in the mDNS TXT records, eliminating the need for additional
818
+ device queries.
819
+
820
+ Note: mDNS discovery requires the mDNS multicast group (224.0.0.251:5353)
821
+ to be accessible. Some network configurations may block multicast traffic.
822
+
823
+ Args:
824
+ timeout: Discovery timeout in seconds (default 15.0)
825
+ max_response_time: Max time to wait for responses
826
+ idle_timeout_multiplier: Idle timeout multiplier
827
+ device_timeout: request timeout set on discovered devices
828
+ max_retries: max retries per request set on discovered devices
829
+
830
+ Yields:
831
+ Device instances as they are discovered
832
+
833
+ Example:
834
+ ```python
835
+ # Process devices as they're discovered
836
+ async for device in discover_mdns():
837
+ print(f"Found: {device.serial}")
838
+ async with device:
839
+ await device.set_power(True)
840
+
841
+ # Or collect all devices first
842
+ devices = []
843
+ async for device in discover_mdns():
844
+ devices.append(device)
845
+ ```
846
+ """
847
+ from lifx.network.mdns.discovery import discover_devices_mdns
848
+
849
+ async for device in discover_devices_mdns(
850
+ timeout=timeout,
851
+ max_response_time=max_response_time,
852
+ idle_timeout_multiplier=idle_timeout_multiplier,
853
+ device_timeout=device_timeout,
854
+ max_retries=max_retries,
855
+ ):
856
+ yield device
857
+
858
+
806
859
  async def find_by_serial(
807
860
  serial: str,
808
861
  timeout: float = DISCOVERY_TIMEOUT,
@@ -996,6 +1049,7 @@ __all__ = [
996
1049
  "LocationGrouping",
997
1050
  "GroupGrouping",
998
1051
  "discover",
1052
+ "discover_mdns",
999
1053
  "find_by_serial",
1000
1054
  "find_by_ip",
1001
1055
  "find_by_label",
lifx/const.py CHANGED
@@ -38,6 +38,19 @@ STATE_REFRESH_DEBOUNCE_MS: Final[int] = 300
38
38
  # Default maximum number of retry attempts for failed requests
39
39
  DEFAULT_MAX_RETRIES: Final[int] = 8
40
40
 
41
+ # ============================================================================
42
+ # mDNS Constants
43
+ # ============================================================================
44
+
45
+ # mDNS multicast address (IPv4)
46
+ MDNS_ADDRESS: Final[str] = "224.0.0.251"
47
+
48
+ # mDNS port
49
+ MDNS_PORT: Final[int] = 5353
50
+
51
+ # LIFX mDNS service type
52
+ LIFX_MDNS_SERVICE: Final[str] = "_lifx._udp.local"
53
+
41
54
  # ============================================================================
42
55
  # HSBK Min/Max Values
43
56
  # ============================================================================
@@ -0,0 +1,58 @@
1
+ """mDNS/DNS-SD discovery for LIFX devices.
2
+
3
+ This module provides mDNS-based discovery using the _lifx._udp.local service type.
4
+ It uses only Python stdlib (no external dependencies).
5
+
6
+ Example:
7
+ Low-level API (raw mDNS records):
8
+ ```python
9
+ async for record in discover_lifx_services():
10
+ print(f"Found: {record.serial} at {record.ip}:{record.port}")
11
+ ```
12
+
13
+ High-level API (device instances):
14
+ ```python
15
+ async for device in discover_devices_mdns():
16
+ print(f"Found {type(device).__name__}: {device.serial}")
17
+ ```
18
+ """
19
+
20
+ from lifx.network.mdns.discovery import (
21
+ create_device_from_record,
22
+ discover_devices_mdns,
23
+ discover_lifx_services,
24
+ )
25
+ from lifx.network.mdns.dns import (
26
+ DnsHeader,
27
+ DnsResourceRecord,
28
+ ParsedDnsResponse,
29
+ SrvData,
30
+ TxtData,
31
+ build_ptr_query,
32
+ parse_dns_response,
33
+ parse_name,
34
+ parse_txt_record,
35
+ )
36
+ from lifx.network.mdns.transport import MdnsTransport
37
+ from lifx.network.mdns.types import LifxServiceRecord
38
+
39
+ __all__ = [
40
+ # Types
41
+ "LifxServiceRecord",
42
+ # Discovery functions
43
+ "discover_lifx_services",
44
+ "discover_devices_mdns",
45
+ "create_device_from_record",
46
+ # DNS parsing
47
+ "DnsHeader",
48
+ "DnsResourceRecord",
49
+ "ParsedDnsResponse",
50
+ "SrvData",
51
+ "TxtData",
52
+ "build_ptr_query",
53
+ "parse_dns_response",
54
+ "parse_name",
55
+ "parse_txt_record",
56
+ # Transport
57
+ "MdnsTransport",
58
+ ]
@@ -0,0 +1,403 @@
1
+ """mDNS discovery for LIFX devices.
2
+
3
+ This module provides discovery functions using mDNS/DNS-SD to find
4
+ LIFX devices on the local network.
5
+
6
+ Example:
7
+ Low-level API (raw service records):
8
+ ```python
9
+ async for record in discover_lifx_services():
10
+ print(f"Found: {record.serial} at {record.ip}:{record.port}")
11
+ ```
12
+
13
+ High-level API (device instances):
14
+ ```python
15
+ async for device in discover_devices_mdns():
16
+ async with device:
17
+ print(f"Found: {await device.get_label()}")
18
+ ```
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import time
25
+ from collections.abc import AsyncGenerator
26
+ from typing import TYPE_CHECKING
27
+
28
+ from lifx.const import (
29
+ DEFAULT_MAX_RETRIES,
30
+ DEFAULT_REQUEST_TIMEOUT,
31
+ DISCOVERY_TIMEOUT,
32
+ IDLE_TIMEOUT_MULTIPLIER,
33
+ LIFX_MDNS_SERVICE,
34
+ MAX_RESPONSE_TIME,
35
+ )
36
+ from lifx.network.mdns.dns import (
37
+ DNS_TYPE_A,
38
+ DNS_TYPE_PTR,
39
+ DNS_TYPE_SRV,
40
+ DNS_TYPE_TXT,
41
+ SrvData,
42
+ TxtData,
43
+ build_ptr_query,
44
+ parse_dns_response,
45
+ )
46
+ from lifx.network.mdns.transport import MdnsTransport
47
+ from lifx.network.mdns.types import LifxServiceRecord
48
+
49
+ if TYPE_CHECKING:
50
+ from lifx.devices.light import Light
51
+
52
+ _LOGGER = logging.getLogger(__name__)
53
+
54
+
55
+ def _extract_lifx_info(
56
+ records: list,
57
+ source_ip: str,
58
+ ) -> LifxServiceRecord | None:
59
+ """Extract LIFX device info from mDNS records.
60
+
61
+ Args:
62
+ records: List of DnsResourceRecord from the response
63
+ source_ip: IP address the response came from
64
+
65
+ Returns:
66
+ LifxServiceRecord if valid LIFX device info found, None otherwise
67
+ """
68
+ # Find records of each type
69
+ srv_data: SrvData | None = None
70
+ txt_data: TxtData | None = None
71
+ a_record_ip: str | None = None
72
+
73
+ for record in records:
74
+ if record.rtype == DNS_TYPE_SRV and isinstance(record.parsed_data, SrvData):
75
+ srv_data = record.parsed_data
76
+ elif record.rtype == DNS_TYPE_TXT and isinstance(record.parsed_data, TxtData):
77
+ txt_data = record.parsed_data
78
+ elif record.rtype == DNS_TYPE_A and isinstance(record.parsed_data, str):
79
+ a_record_ip = record.parsed_data
80
+
81
+ # Need at least TXT record to identify the device
82
+ if txt_data is None:
83
+ return None
84
+
85
+ # Extract required fields from TXT record
86
+ serial = txt_data.pairs.get("id", "").lower()
87
+ product_id_str = txt_data.pairs.get("p", "")
88
+ firmware = txt_data.pairs.get("fw", "")
89
+
90
+ # Validate required fields
91
+ if not serial or not product_id_str:
92
+ return None
93
+
94
+ try:
95
+ product_id = int(product_id_str)
96
+ except ValueError:
97
+ return None
98
+
99
+ # Get port from SRV record or use default
100
+ port = srv_data.port if srv_data else 56700
101
+
102
+ # Get IP from A record or use source IP
103
+ ip = a_record_ip if a_record_ip else source_ip
104
+
105
+ return LifxServiceRecord(
106
+ serial=serial,
107
+ ip=ip,
108
+ port=port,
109
+ product_id=product_id,
110
+ firmware=firmware,
111
+ )
112
+
113
+
114
+ def create_device_from_record(
115
+ record: LifxServiceRecord,
116
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
117
+ max_retries: int = DEFAULT_MAX_RETRIES,
118
+ ) -> Light | None:
119
+ """Create appropriate device class based on product ID from mDNS record.
120
+
121
+ Uses the product registry to determine device capabilities and instantiate
122
+ the correct device class (Light, MatrixLight, MultiZoneLight, etc.).
123
+
124
+ Args:
125
+ record: LifxServiceRecord from mDNS discovery
126
+ timeout: Request timeout for the device
127
+ max_retries: Maximum retry attempts for requests
128
+
129
+ Returns:
130
+ Device instance of the appropriate type, or None if device should be skipped
131
+ (e.g., relay/button-only devices)
132
+
133
+ Example:
134
+ ```python
135
+ async for record in discover_lifx_services():
136
+ device = create_device_from_record(record)
137
+ if device:
138
+ async with device:
139
+ print(f"Device: {await device.get_label()}")
140
+ ```
141
+ """
142
+ from lifx.devices.ceiling import CeilingLight
143
+ from lifx.devices.hev import HevLight
144
+ from lifx.devices.infrared import InfraredLight
145
+ from lifx.devices.light import Light
146
+ from lifx.devices.matrix import MatrixLight
147
+ from lifx.devices.multizone import MultiZoneLight
148
+ from lifx.products import get_product, is_ceiling_product
149
+
150
+ product = get_product(record.product_id)
151
+ kwargs = {
152
+ "serial": record.serial,
153
+ "ip": record.ip,
154
+ "port": record.port,
155
+ "timeout": timeout,
156
+ "max_retries": max_retries,
157
+ }
158
+
159
+ # Priority-based selection matching DiscoveredDevice.create_device()
160
+ if is_ceiling_product(record.product_id):
161
+ return CeilingLight(**kwargs)
162
+ if product.has_matrix:
163
+ return MatrixLight(**kwargs)
164
+ if product.has_multizone:
165
+ return MultiZoneLight(**kwargs)
166
+ if product.has_infrared:
167
+ return InfraredLight(**kwargs)
168
+ if product.has_hev:
169
+ return HevLight(**kwargs)
170
+ if product.has_relays or (product.has_buttons and not product.has_color):
171
+ return None
172
+ return Light(**kwargs)
173
+
174
+
175
+ async def discover_lifx_services(
176
+ timeout: float = DISCOVERY_TIMEOUT,
177
+ max_response_time: float = MAX_RESPONSE_TIME,
178
+ idle_timeout_multiplier: float = IDLE_TIMEOUT_MULTIPLIER,
179
+ ) -> AsyncGenerator[LifxServiceRecord, None]:
180
+ """Discover LIFX devices via mDNS and yield service records.
181
+
182
+ Sends an mDNS PTR query for _lifx._udp.local and yields service records
183
+ as devices respond. Records are deduplicated by serial number.
184
+
185
+ This is the low-level API that provides raw mDNS data. For device instances,
186
+ use discover_devices_mdns() instead.
187
+
188
+ Args:
189
+ timeout: Overall discovery timeout in seconds
190
+ max_response_time: Maximum expected response time
191
+ idle_timeout_multiplier: Multiplier for idle timeout
192
+
193
+ Yields:
194
+ LifxServiceRecord for each discovered device
195
+
196
+ Example:
197
+ ```python
198
+ async for record in discover_lifx_services(timeout=10.0):
199
+ print(f"Found: {record.serial} (product {record.product_id})")
200
+ print(f" IP: {record.ip}:{record.port}")
201
+ print(f" Firmware: {record.firmware}")
202
+ ```
203
+ """
204
+ seen_serials: set[str] = set()
205
+ start_time = time.time()
206
+
207
+ async with MdnsTransport() as transport:
208
+ # Build and send PTR query
209
+ query = build_ptr_query(LIFX_MDNS_SERVICE)
210
+ request_time = time.time()
211
+
212
+ _LOGGER.debug(
213
+ {
214
+ "class": "discover_lifx_services",
215
+ "method": "discover",
216
+ "action": "sending_query",
217
+ "service": LIFX_MDNS_SERVICE,
218
+ "timeout": timeout,
219
+ }
220
+ )
221
+
222
+ await transport.send(query)
223
+
224
+ # Calculate idle timeout
225
+ idle_timeout = max_response_time * idle_timeout_multiplier
226
+ last_response_time = request_time
227
+
228
+ # Collect responses with dynamic timeout
229
+ while True:
230
+ # Calculate elapsed time since last response
231
+ elapsed_since_last = time.time() - last_response_time
232
+
233
+ # Stop if we've been idle too long
234
+ if elapsed_since_last >= idle_timeout:
235
+ _LOGGER.debug(
236
+ {
237
+ "class": "discover_lifx_services",
238
+ "method": "discover",
239
+ "action": "idle_timeout",
240
+ "idle_time": elapsed_since_last,
241
+ "idle_timeout": idle_timeout,
242
+ }
243
+ )
244
+ break
245
+
246
+ # Stop if we've exceeded the overall timeout
247
+ if time.time() - request_time >= timeout:
248
+ _LOGGER.debug(
249
+ {
250
+ "class": "discover_lifx_services",
251
+ "method": "discover",
252
+ "action": "overall_timeout",
253
+ "elapsed": time.time() - request_time,
254
+ "timeout": timeout,
255
+ }
256
+ )
257
+ break
258
+
259
+ # Calculate remaining timeout
260
+ remaining_idle = idle_timeout - elapsed_since_last
261
+ remaining_overall = timeout - (time.time() - request_time)
262
+ remaining = min(remaining_idle, remaining_overall)
263
+
264
+ try:
265
+ data, addr = await transport.receive(timeout=remaining)
266
+ response_timestamp = time.time()
267
+
268
+ except Exception:
269
+ # Timeout or error - stop collecting
270
+ _LOGGER.debug(
271
+ {
272
+ "class": "discover_lifx_services",
273
+ "method": "discover",
274
+ "action": "no_responses",
275
+ }
276
+ )
277
+ break
278
+
279
+ try:
280
+ # Parse DNS response
281
+ response = parse_dns_response(data)
282
+
283
+ # Only process responses (not queries)
284
+ if not response.header.is_response:
285
+ continue
286
+
287
+ # Check if this is a LIFX response (has PTR for _lifx._udp.local)
288
+ has_lifx_ptr = any(
289
+ r.rtype == DNS_TYPE_PTR and LIFX_MDNS_SERVICE in r.name
290
+ for r in response.records
291
+ )
292
+
293
+ if not has_lifx_ptr:
294
+ # Might still be a LIFX device responding without PTR
295
+ # Check TXT records for LIFX format
296
+ has_lifx_txt = any(
297
+ r.rtype == DNS_TYPE_TXT
298
+ and isinstance(r.parsed_data, TxtData)
299
+ and "id" in r.parsed_data.pairs
300
+ and "p" in r.parsed_data.pairs
301
+ for r in response.records
302
+ )
303
+ if not has_lifx_txt:
304
+ continue
305
+
306
+ # Extract device info from records
307
+ record = _extract_lifx_info(response.records, addr[0])
308
+
309
+ if record is None:
310
+ continue
311
+
312
+ # Deduplicate by serial
313
+ if record.serial in seen_serials:
314
+ continue
315
+
316
+ seen_serials.add(record.serial)
317
+
318
+ _LOGGER.debug(
319
+ {
320
+ "class": "discover_lifx_services",
321
+ "method": "discover",
322
+ "action": "device_found",
323
+ "serial": record.serial,
324
+ "ip": record.ip,
325
+ "port": record.port,
326
+ "product_id": record.product_id,
327
+ }
328
+ )
329
+
330
+ yield record
331
+
332
+ # Update last response time for idle timeout
333
+ last_response_time = response_timestamp
334
+
335
+ except Exception as e:
336
+ _LOGGER.debug(
337
+ {
338
+ "class": "discover_lifx_services",
339
+ "method": "discover",
340
+ "action": "parse_error",
341
+ "error": str(e),
342
+ "source_ip": addr[0],
343
+ }
344
+ )
345
+ continue
346
+
347
+ _LOGGER.debug(
348
+ {
349
+ "class": "discover_lifx_services",
350
+ "method": "discover",
351
+ "action": "complete",
352
+ "devices_found": len(seen_serials),
353
+ "elapsed": time.time() - start_time,
354
+ }
355
+ )
356
+
357
+
358
+ async def discover_devices_mdns(
359
+ timeout: float = DISCOVERY_TIMEOUT,
360
+ max_response_time: float = MAX_RESPONSE_TIME,
361
+ idle_timeout_multiplier: float = IDLE_TIMEOUT_MULTIPLIER,
362
+ device_timeout: float = DEFAULT_REQUEST_TIMEOUT,
363
+ max_retries: int = DEFAULT_MAX_RETRIES,
364
+ ) -> AsyncGenerator[Light, None]:
365
+ """Discover LIFX devices via mDNS and yield device instances.
366
+
367
+ This is the high-level API that yields fully-typed device instances
368
+ (Light, MatrixLight, MultiZoneLight, etc.) based on product capabilities.
369
+
370
+ Devices that are not lights (relays, buttons without color) are automatically
371
+ filtered out and not yielded.
372
+
373
+ Args:
374
+ timeout: Overall discovery timeout in seconds
375
+ max_response_time: Maximum expected response time
376
+ idle_timeout_multiplier: Multiplier for idle timeout
377
+ device_timeout: Request timeout for created devices
378
+ max_retries: Maximum retry attempts for device requests
379
+
380
+ Yields:
381
+ Device instances (Light, MatrixLight, etc.) as they are discovered
382
+
383
+ Example:
384
+ ```python
385
+ async for device in discover_devices_mdns(timeout=10.0):
386
+ async with device:
387
+ label = await device.get_label()
388
+ print(f"{type(device).__name__}: {label} at {device.ip}")
389
+ ```
390
+ """
391
+ async for record in discover_lifx_services(
392
+ timeout=timeout,
393
+ max_response_time=max_response_time,
394
+ idle_timeout_multiplier=idle_timeout_multiplier,
395
+ ):
396
+ device = create_device_from_record(
397
+ record,
398
+ timeout=device_timeout,
399
+ max_retries=max_retries,
400
+ )
401
+
402
+ if device is not None:
403
+ yield device