lifx-async 4.7.5__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/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.5.dist-info → lifx_async-4.8.0.dist-info}/METADATA +1 -1
- {lifx_async-4.7.5.dist-info → lifx_async-4.8.0.dist-info}/RECORD +12 -7
- {lifx_async-4.7.5.dist-info → lifx_async-4.8.0.dist-info}/WHEEL +0 -0
- {lifx_async-4.7.5.dist-info → lifx_async-4.8.0.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
|
lifx/network/mdns/dns.py
ADDED
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
lifx/__init__.py,sha256=
|
|
2
|
-
lifx/api.py,sha256=
|
|
1
|
+
lifx/__init__.py,sha256=DKHG1vFJvPw_LpMkQgZN85gyOSD8dnceq6LnEGgR9vs,2810
|
|
2
|
+
lifx/api.py,sha256=xHUM6NDgv8V8tLpDtpnPVJNGcVU5y_8da9wI3pnm06Y,35903
|
|
3
3
|
lifx/color.py,sha256=wcmeeiBmOAjunInERNd6rslKvBEpV4vfjwwiZ8v7H8A,17877
|
|
4
|
-
lifx/const.py,sha256=
|
|
4
|
+
lifx/const.py,sha256=5LEh4h0-bEJlOfpG8fgyht0LkAEV9jkkpuCiuatBhEI,3840
|
|
5
5
|
lifx/exceptions.py,sha256=pikAMppLn7gXyjiQVWM_tSvXKNh-g366nG_UWyqpHhc,815
|
|
6
6
|
lifx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
lifx/devices/__init__.py,sha256=4b5QtO0EFWxIqN2lUYgM8uLjWyHI5hUcReiF9QCjCGw,1061
|
|
@@ -25,6 +25,11 @@ lifx/network/connection.py,sha256=aerPiYWf096lq8oBiS7JfE4k-P18GS50mNEC4TYa2g8,38
|
|
|
25
25
|
lifx/network/discovery.py,sha256=syFfkDYWo0AEoBdEBjWqBm4K7UJwZW5x2K0FBMiA2I0,24186
|
|
26
26
|
lifx/network/message.py,sha256=jCLC9v0tbBi54g5CaHLFM_nP1Izu8kJmo2tt23HHBbA,2600
|
|
27
27
|
lifx/network/transport.py,sha256=8QS0YV32rdP0EDiPEwuvZXbplRWL08pmjKybd87mkZ0,11070
|
|
28
|
+
lifx/network/mdns/__init__.py,sha256=LlZgsFe6q5_SIXvXqtuZ_O9tJbcJZ-nsFkD2_wD8_TM,1412
|
|
29
|
+
lifx/network/mdns/discovery.py,sha256=EZ2zlJmy96rMDmu5J-68ystXJ2gYa18zTYP3iqmTGgU,13200
|
|
30
|
+
lifx/network/mdns/dns.py,sha256=OsvNSxLepIG3Nhw-kkQF3JrBYI-ikod5SHD2HO5_yGE,9363
|
|
31
|
+
lifx/network/mdns/transport.py,sha256=k8gVZCvU-gksV2dV-jm2YG-_kuKWx0whtP3Va0EjCd8,10242
|
|
32
|
+
lifx/network/mdns/types.py,sha256=9fhH5iuMQxLkFPhmFTf2-kOcUNoWEu7LrN15Qr9tFE0,990
|
|
28
33
|
lifx/products/__init__.py,sha256=pf2O-fzt6nOrQd-wmzhiog91tMiGa-dDbaSNtU2ZQfE,764
|
|
29
34
|
lifx/products/generator.py,sha256=5bDFfrJ8ocwuhEr4dZB4LpVcqOqC3KxJSDiphPMu8CI,15660
|
|
30
35
|
lifx/products/quirks.py,sha256=B8Kb4pxaXmovMbjgXRfPPWre5JEvJrn8d6PAWK_FT1U,2544
|
|
@@ -42,7 +47,7 @@ lifx/theme/canvas.py,sha256=4h7lgN8iu_OdchObGDgbxTqQLCb-FRKC-M-YCWef_i4,8048
|
|
|
42
47
|
lifx/theme/generators.py,sha256=nq3Yvntq_h-eFHbmmow3LcAdA_hEbRRaP5mv9Bydrjk,6435
|
|
43
48
|
lifx/theme/library.py,sha256=tKlKZNqJp8lRGDnilWyDm_Qr1vCRGGwuvWVS82anNpQ,21326
|
|
44
49
|
lifx/theme/theme.py,sha256=qMEx_8E41C0Cc6f083XHiAXEglTv4YlXW0UFsG1rQKg,5521
|
|
45
|
-
lifx_async-4.
|
|
46
|
-
lifx_async-4.
|
|
47
|
-
lifx_async-4.
|
|
48
|
-
lifx_async-4.
|
|
50
|
+
lifx_async-4.8.0.dist-info/METADATA,sha256=kwOq2Vzad06tvCCZiq7JhT7lMkO4wEZI5JHxyIPSfbE,2609
|
|
51
|
+
lifx_async-4.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
52
|
+
lifx_async-4.8.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
|
|
53
|
+
lifx_async-4.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|