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 +6 -0
- lifx/api.py +54 -0
- lifx/const.py +13 -0
- 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/products/generator.py +7 -5
- lifx/protocol/generator.py +7 -5
- {lifx_async-4.7.5.dist-info → lifx_async-4.8.1.dist-info}/METADATA +1 -1
- {lifx_async-4.7.5.dist-info → lifx_async-4.8.1.dist-info}/RECORD +14 -9
- {lifx_async-4.7.5.dist-info → lifx_async-4.8.1.dist-info}/WHEEL +0 -0
- {lifx_async-4.7.5.dist-info → lifx_async-4.8.1.dist-info}/licenses/LICENSE +0 -0
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
|