lifx-async 4.7.4__py3-none-any.whl → 4.8.0__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 +6 -0
- lifx/api.py +54 -0
- lifx/const.py +13 -0
- lifx/devices/ceiling.py +75 -17
- lifx/network/mdns/__init__.py +58 -0
- lifx/network/mdns/discovery.py +403 -0
- lifx/network/mdns/dns.py +356 -0
- lifx/network/mdns/transport.py +313 -0
- lifx/network/mdns/types.py +35 -0
- {lifx_async-4.7.4.dist-info → lifx_async-4.8.0.dist-info}/METADATA +1 -1
- {lifx_async-4.7.4.dist-info → lifx_async-4.8.0.dist-info}/RECORD +13 -8
- {lifx_async-4.7.4.dist-info → lifx_async-4.8.0.dist-info}/WHEEL +0 -0
- {lifx_async-4.7.4.dist-info → lifx_async-4.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|