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 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
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.7.5
3
+ Version: 4.8.0
4
4
  Summary: A modern, type-safe, async Python library for controlling LIFX lights
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -1,7 +1,7 @@
1
- lifx/__init__.py,sha256=aiMLKLhmcXADJyeISNHIZ_Nuc6jEhPG8-I_R3peWpwo,2610
2
- lifx/api.py,sha256=XV2CJLi3N9UpbZuqVE56K-MEsSaqOte9WhcBbhkmHQM,33962
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=cf_O_3TqJjIBXF1tI35PkJ1JOhmy4tRt14PSa63pilA,3471
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.7.5.dist-info/METADATA,sha256=1SN5XqtWLHrF_JtLHyWGNSCCeoajcu32g0MWFC48VIM,2609
46
- lifx_async-4.7.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
- lifx_async-4.7.5.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
48
- lifx_async-4.7.5.dist-info/RECORD,,
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,,