lifx-async 4.7.4__py3-none-any.whl → 4.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lifx/__init__.py 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
  # ============================================================================
lifx/devices/ceiling.py CHANGED
@@ -347,6 +347,22 @@ class CeilingLight(MatrixLight):
347
347
 
348
348
  return layout.downlight_zones
349
349
 
350
+ @property
351
+ def downlight_zone_count(self) -> int:
352
+ """Number of downlight zones.
353
+
354
+ Returns:
355
+ Zone count (63 for standard 8x8, 127 for Capsule 16x8)
356
+
357
+ Raises:
358
+ LifxError: If device version is not available or not a Ceiling product
359
+ """
360
+ # downlight_zones is slice(0, N), so stop equals the count
361
+ stop = self.downlight_zones.stop
362
+ if stop is None:
363
+ raise LifxError("Invalid downlight zones configuration")
364
+ return stop
365
+
350
366
  @property
351
367
  def uplight_is_on(self) -> bool:
352
368
  """True if uplight component is currently on.
@@ -496,7 +512,7 @@ class CeilingLight(MatrixLight):
496
512
  "Cannot set downlight color with brightness=0. "
497
513
  "Use turn_downlight_off() instead."
498
514
  )
499
- downlight_colors = [colors] * len(range(*self.downlight_zones.indices(256)))
515
+ downlight_colors = [colors] * self.downlight_zone_count
500
516
  else:
501
517
  if all(c.brightness == 0 for c in colors):
502
518
  raise ValueError(
@@ -504,10 +520,10 @@ class CeilingLight(MatrixLight):
504
520
  "Use turn_downlight_off() instead."
505
521
  )
506
522
 
507
- expected_count = len(range(*self.downlight_zones.indices(256)))
508
- if len(colors) != expected_count:
523
+ if len(colors) != self.downlight_zone_count:
509
524
  raise ValueError(
510
- f"Expected {expected_count} colors for downlight, got {len(colors)}"
525
+ f"Expected {self.downlight_zone_count} colors for downlight, "
526
+ f"got {len(colors)}"
511
527
  )
512
528
  downlight_colors = colors
513
529
 
@@ -684,10 +700,6 @@ class CeilingLight(MatrixLight):
684
700
  ValueError: If list length doesn't match downlight zone count
685
701
  LifxTimeoutError: Device did not respond
686
702
  """
687
- # Number of downlight zones equals the uplight zone index
688
- # (downlight is zones 0 to uplight_zone-1)
689
- downlight_zone_count = self.uplight_zone
690
-
691
703
  # Validate provided colors early
692
704
  if colors is not None:
693
705
  if isinstance(colors, HSBK):
@@ -696,9 +708,9 @@ class CeilingLight(MatrixLight):
696
708
  else:
697
709
  if all(c.brightness == 0 for c in colors):
698
710
  raise ValueError("Cannot turn on downlight with brightness=0")
699
- if len(colors) != downlight_zone_count:
711
+ if len(colors) != self.downlight_zone_count:
700
712
  raise ValueError(
701
- f"Expected {downlight_zone_count} colors for downlight, "
713
+ f"Expected {self.downlight_zone_count} colors for downlight, "
702
714
  f"got {len(colors)}"
703
715
  )
704
716
 
@@ -711,7 +723,7 @@ class CeilingLight(MatrixLight):
711
723
  # Determine target colors (pass pre-fetched colors to avoid extra fetch)
712
724
  if colors is not None:
713
725
  if isinstance(colors, HSBK):
714
- target_colors = [colors] * downlight_zone_count
726
+ target_colors = [colors] * self.downlight_zone_count
715
727
  else:
716
728
  target_colors = list(colors)
717
729
  else:
@@ -750,7 +762,7 @@ class CeilingLight(MatrixLight):
750
762
  # Light is already on - determine target colors first, then set
751
763
  if colors is not None:
752
764
  if isinstance(colors, HSBK):
753
- target_colors = [colors] * downlight_zone_count
765
+ target_colors = [colors] * self.downlight_zone_count
754
766
  else:
755
767
  target_colors = list(colors)
756
768
  else:
@@ -824,6 +836,54 @@ class CeilingLight(MatrixLight):
824
836
  if turning_off and self._state_file:
825
837
  self._save_state_to_file()
826
838
 
839
+ async def set_color(self, color: HSBK, duration: float = 0.0) -> None:
840
+ """Set light color, updating component state tracking.
841
+
842
+ Overrides Light.set_color() to track the color change in the ceiling
843
+ light's component state. When set_color() is called, all zones (uplight
844
+ and downlight) are set to the same color. This override ensures that
845
+ the cached component colors stay in sync so that subsequent component
846
+ control methods (like turn_uplight_on or turn_downlight_on) use the
847
+ correct color values.
848
+
849
+ Args:
850
+ color: HSBK color to set for the entire light
851
+ duration: Transition duration in seconds (default 0.0)
852
+
853
+ Raises:
854
+ LifxDeviceNotFoundError: If device is not connected
855
+ LifxTimeoutError: If device does not respond
856
+ LifxUnsupportedCommandError: If device doesn't support this command
857
+
858
+ Example:
859
+ ```python
860
+ from lifx.color import HSBK
861
+
862
+ # Set entire ceiling light to warm white
863
+ await ceiling.set_color(
864
+ HSBK(hue=0, saturation=0, brightness=1.0, kelvin=2700)
865
+ )
866
+
867
+ # Later component control will use this color
868
+ await ceiling.turn_uplight_off() # Uplight off
869
+ await ceiling.turn_uplight_on() # Restores to warm white
870
+ ```
871
+ """
872
+ # Call parent to perform actual color change
873
+ await super().set_color(color, duration)
874
+
875
+ # Update cached component colors - all zones now have the same color
876
+ self._last_uplight_color = color
877
+ self._last_downlight_colors = [color] * self.downlight_zone_count
878
+
879
+ # Also update stored state for restoration
880
+ self._stored_uplight_state = color
881
+ self._stored_downlight_state = [color] * self.downlight_zone_count
882
+
883
+ # Persist if enabled
884
+ if self._state_file:
885
+ self._save_state_to_file()
886
+
827
887
  async def turn_downlight_off(
828
888
  self, colors: HSBK | list[HSBK] | None = None, duration: float = 0.0
829
889
  ) -> None:
@@ -845,8 +905,6 @@ class CeilingLight(MatrixLight):
845
905
  Note:
846
906
  Sets all downlight zone brightness to 0 on device while preserving H, S, K.
847
907
  """
848
- expected_count = len(range(*self.downlight_zones.indices(256)))
849
-
850
908
  # Validate provided colors early (before fetching)
851
909
  stored_colors: list[HSBK] | None = None
852
910
  if colors is not None:
@@ -856,16 +914,16 @@ class CeilingLight(MatrixLight):
856
914
  "Provided color cannot have brightness=0. "
857
915
  "Omit the parameter to use current colors."
858
916
  )
859
- stored_colors = [colors] * expected_count
917
+ stored_colors = [colors] * self.downlight_zone_count
860
918
  else:
861
919
  if all(c.brightness == 0 for c in colors):
862
920
  raise ValueError(
863
921
  "Provided colors cannot have brightness=0. "
864
922
  "Omit the parameter to use current colors."
865
923
  )
866
- if len(colors) != expected_count:
924
+ if len(colors) != self.downlight_zone_count:
867
925
  raise ValueError(
868
- f"Expected {expected_count} colors for downlight, "
926
+ f"Expected {self.downlight_zone_count} colors for downlight, "
869
927
  f"got {len(colors)}"
870
928
  )
871
929
  stored_colors = list(colors)
@@ -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
+ ]