lifx-async 5.0.0__py3-none-any.whl → 5.1.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
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  from importlib.metadata import version as get_version
9
9
 
10
+ from lifx.animation import Animator, AnimatorStats
10
11
  from lifx.api import (
11
12
  DeviceGroup,
12
13
  discover,
@@ -95,6 +96,9 @@ __all__ = [
95
96
  "LIFXEffect",
96
97
  "EffectPulse",
97
98
  "EffectColorloop",
99
+ # Animation
100
+ "Animator",
101
+ "AnimatorStats",
98
102
  # Themes
99
103
  "Theme",
100
104
  "ThemeLibrary",
@@ -0,0 +1,87 @@
1
+ """LIFX Animation Module.
2
+
3
+ This module provides efficient animation support for LIFX devices,
4
+ optimized for high-frequency frame delivery.
5
+
6
+ Architecture:
7
+ FrameBuffer (orientation) -> PacketGenerator -> Direct UDP
8
+
9
+ Key Components:
10
+ - Orientation: Tile orientation remapping with LRU-cached lookup tables
11
+ - FrameBuffer: Orientation mapping for matrix devices
12
+ - PacketGenerator: Device-specific packet generation (matrix, multizone)
13
+ - Animator: High-level class that sends frames via direct UDP
14
+
15
+ Quick Start:
16
+ ```python
17
+ from lifx import Animator, MatrixLight
18
+
19
+ async with await MatrixLight.from_ip("192.168.1.100") as device:
20
+ # Query device once for tile info
21
+ animator = await Animator.for_matrix(device)
22
+
23
+ # Device connection closed - animator sends via direct UDP
24
+ while running:
25
+ # Send HSBK frame (protocol-ready uint16 values)
26
+ hsbk_frame = [(65535, 65535, 65535, 3500)] * animator.pixel_count
27
+ stats = animator.send_frame(hsbk_frame) # Synchronous for speed
28
+ print(f"Sent {stats.packets_sent} packets")
29
+ await asyncio.sleep(1 / 30) # 30 FPS
30
+
31
+ animator.close()
32
+ ```
33
+
34
+ HSBK Format:
35
+ All color data uses protocol-ready uint16 values:
36
+ - Hue: 0-65535 (maps to 0-360 degrees)
37
+ - Saturation: 0-65535 (maps to 0.0-1.0)
38
+ - Brightness: 0-65535 (maps to 0.0-1.0)
39
+ - Kelvin: 1500-9000
40
+ """
41
+
42
+ # Animator - High-level API
43
+ from lifx.animation.animator import (
44
+ Animator,
45
+ AnimatorStats,
46
+ )
47
+
48
+ # FrameBuffer - Orientation and canvas mapping
49
+ from lifx.animation.framebuffer import (
50
+ FrameBuffer,
51
+ TileRegion,
52
+ )
53
+
54
+ # Orientation - Tile remapping
55
+ from lifx.animation.orientation import (
56
+ Orientation,
57
+ build_orientation_lut,
58
+ )
59
+
60
+ # Packet generators
61
+ from lifx.animation.packets import (
62
+ HEADER_SIZE,
63
+ SEQUENCE_OFFSET,
64
+ MatrixPacketGenerator,
65
+ MultiZonePacketGenerator,
66
+ PacketGenerator,
67
+ PacketTemplate,
68
+ )
69
+
70
+ __all__ = [
71
+ # Animator (high-level API)
72
+ "Animator",
73
+ "AnimatorStats",
74
+ # FrameBuffer
75
+ "FrameBuffer",
76
+ "TileRegion",
77
+ # Orientation
78
+ "Orientation",
79
+ "build_orientation_lut",
80
+ # Packet generators
81
+ "PacketGenerator",
82
+ "PacketTemplate",
83
+ "MatrixPacketGenerator",
84
+ "MultiZonePacketGenerator",
85
+ "HEADER_SIZE",
86
+ "SEQUENCE_OFFSET",
87
+ ]
@@ -0,0 +1,323 @@
1
+ """High-level Animator class for LIFX device animation.
2
+
3
+ This module provides the Animator class, which sends animation frames
4
+ directly via UDP for maximum throughput - no connection layer overhead.
5
+
6
+ The factory methods query the device once for configuration (tile info,
7
+ zone count), then the Animator sends frames via raw UDP packets with
8
+ prebaked packet templates for zero-allocation performance.
9
+
10
+ Example:
11
+ ```python
12
+ from lifx.animation import Animator
13
+
14
+ async with await MatrixLight.from_ip("192.168.1.100") as device:
15
+ # Query device once for tile info
16
+ animator = await Animator.for_matrix(device)
17
+
18
+ # Device connection no longer needed - animator sends via direct UDP
19
+ while running:
20
+ stats = animator.send_frame(frame)
21
+ await asyncio.sleep(1 / 30) # 30 FPS
22
+
23
+ animator.close()
24
+ ```
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import random
30
+ import socket
31
+ import time
32
+ from dataclasses import dataclass
33
+ from typing import TYPE_CHECKING
34
+
35
+ from lifx.animation.framebuffer import FrameBuffer
36
+ from lifx.animation.packets import (
37
+ SEQUENCE_OFFSET,
38
+ MatrixPacketGenerator,
39
+ MultiZonePacketGenerator,
40
+ PacketGenerator,
41
+ PacketTemplate,
42
+ )
43
+ from lifx.const import LIFX_UDP_PORT
44
+ from lifx.protocol.models import Serial
45
+
46
+ if TYPE_CHECKING:
47
+ from lifx.devices.matrix import MatrixLight
48
+ from lifx.devices.multizone import MultiZoneLight
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class AnimatorStats:
53
+ """Statistics about a frame send operation.
54
+
55
+ Attributes:
56
+ packets_sent: Number of packets sent
57
+ total_time_ms: Total time for the operation in milliseconds
58
+ """
59
+
60
+ packets_sent: int
61
+ total_time_ms: float
62
+
63
+
64
+ class Animator:
65
+ """High-level animator for LIFX devices.
66
+
67
+ Sends animation frames directly via UDP for maximum throughput.
68
+ No connection layer, no ACKs, no waiting - just fire packets as
69
+ fast as possible.
70
+
71
+ All packets are prebaked at initialization time. Per-frame, only
72
+ color data and sequence numbers are updated in place before sending.
73
+
74
+ Attributes:
75
+ pixel_count: Total number of pixels/zones
76
+
77
+ Example:
78
+ ```python
79
+ async with await MatrixLight.from_ip("192.168.1.100") as device:
80
+ animator = await Animator.for_matrix(device)
81
+
82
+ # No connection needed after this - direct UDP
83
+ while running:
84
+ stats = animator.send_frame(frame)
85
+ await asyncio.sleep(1 / 30) # 30 FPS
86
+
87
+ animator.close()
88
+ ```
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ ip: str,
94
+ serial: Serial,
95
+ framebuffer: FrameBuffer,
96
+ packet_generator: PacketGenerator,
97
+ port: int = LIFX_UDP_PORT,
98
+ ) -> None:
99
+ """Initialize animator for direct UDP sending.
100
+
101
+ Use the `for_matrix()` or `for_multizone()` class methods for
102
+ automatic configuration from a device.
103
+
104
+ Args:
105
+ ip: Device IP address
106
+ serial: Device serial number
107
+ framebuffer: Configured FrameBuffer for orientation mapping
108
+ packet_generator: Configured PacketGenerator for the device
109
+ port: UDP port (default: 56700)
110
+ """
111
+ self._ip = ip
112
+ self._port = port
113
+ self._serial = serial
114
+ self._framebuffer = framebuffer
115
+ self._packet_generator = packet_generator
116
+
117
+ # Protocol source ID (random, identifies this client)
118
+ self._source = random.randint(1, 0xFFFFFFFF) # nosec B311
119
+
120
+ # Sequence number (0-255, wraps around)
121
+ self._sequence = 0
122
+
123
+ # Create prebaked packet templates
124
+ self._templates: list[PacketTemplate] = packet_generator.create_templates(
125
+ source=self._source,
126
+ target=serial.value,
127
+ )
128
+
129
+ # UDP socket (created lazily)
130
+ self._socket: socket.socket | None = None
131
+
132
+ @classmethod
133
+ async def for_matrix(
134
+ cls,
135
+ device: MatrixLight,
136
+ ) -> Animator:
137
+ """Create an Animator configured for a MatrixLight device.
138
+
139
+ Queries the device for tile information, then returns an animator
140
+ that sends frames via direct UDP (no device connection needed
141
+ after creation).
142
+
143
+ Args:
144
+ device: MatrixLight device (must be connected)
145
+
146
+ Returns:
147
+ Configured Animator instance
148
+
149
+ Example:
150
+ ```python
151
+ async with await MatrixLight.from_ip("192.168.1.100") as device:
152
+ animator = await Animator.for_matrix(device)
153
+
154
+ # Device connection closed, animator still works via UDP
155
+ while running:
156
+ stats = animator.send_frame(frame)
157
+ await asyncio.sleep(1 / 30) # 30 FPS
158
+ ```
159
+ """
160
+ # Get device info
161
+ ip = device.ip
162
+ serial = Serial.from_string(device.serial)
163
+
164
+ # Ensure we have tile chain
165
+ if device.device_chain is None:
166
+ await device.get_device_chain()
167
+
168
+ tiles = device.device_chain
169
+ if not tiles:
170
+ raise ValueError("Device has no tiles")
171
+
172
+ # Create framebuffer with orientation correction
173
+ framebuffer = await FrameBuffer.for_matrix(device)
174
+
175
+ # Create packet generator
176
+ packet_generator = MatrixPacketGenerator(
177
+ tile_count=len(tiles),
178
+ tile_width=tiles[0].width,
179
+ tile_height=tiles[0].height,
180
+ )
181
+
182
+ return cls(ip, serial, framebuffer, packet_generator)
183
+
184
+ @classmethod
185
+ async def for_multizone(
186
+ cls,
187
+ device: MultiZoneLight,
188
+ ) -> Animator:
189
+ """Create an Animator configured for a MultiZoneLight device.
190
+
191
+ Only devices with extended multizone capability are supported.
192
+ Queries the device for zone count, then returns an animator
193
+ that sends frames via direct UDP.
194
+
195
+ Args:
196
+ device: MultiZoneLight device (must be connected and support
197
+ extended multizone protocol)
198
+
199
+ Returns:
200
+ Configured Animator instance
201
+
202
+ Raises:
203
+ ValueError: If device doesn't support extended multizone
204
+
205
+ Example:
206
+ ```python
207
+ async with await MultiZoneLight.from_ip("192.168.1.100") as device:
208
+ animator = await Animator.for_multizone(device)
209
+
210
+ # Device connection closed, animator still works via UDP
211
+ while running:
212
+ stats = animator.send_frame(frame)
213
+ await asyncio.sleep(1 / 30) # 30 FPS
214
+ ```
215
+ """
216
+ # Ensure capabilities are loaded
217
+ if device.capabilities is None:
218
+ await device._ensure_capabilities()
219
+
220
+ # Check extended multizone capability
221
+ has_extended = bool(
222
+ device.capabilities and device.capabilities.has_extended_multizone
223
+ )
224
+ if not has_extended:
225
+ raise ValueError(
226
+ "Device does not support extended multizone protocol. "
227
+ "Only extended multizone devices are supported for animation."
228
+ )
229
+
230
+ # Get device info
231
+ ip = device.ip
232
+ serial = Serial.from_string(device.serial)
233
+
234
+ # Create framebuffer (no orientation for multizone)
235
+ framebuffer = await FrameBuffer.for_multizone(device)
236
+
237
+ # Get zone count
238
+ zone_count = await device.get_zone_count()
239
+
240
+ # Create packet generator
241
+ packet_generator = MultiZonePacketGenerator(zone_count=zone_count)
242
+
243
+ return cls(ip, serial, framebuffer, packet_generator)
244
+
245
+ @property
246
+ def pixel_count(self) -> int:
247
+ """Get total number of input pixels (canvas size for multi-tile)."""
248
+ # For multi-tile devices, this returns the canvas size
249
+ # For single-tile/multizone, this returns device pixel count
250
+ return self._framebuffer.canvas_size
251
+
252
+ @property
253
+ def canvas_width(self) -> int:
254
+ """Get width of the logical canvas in pixels."""
255
+ return self._framebuffer.canvas_width
256
+
257
+ @property
258
+ def canvas_height(self) -> int:
259
+ """Get height of the logical canvas in pixels."""
260
+ return self._framebuffer.canvas_height
261
+
262
+ def send_frame(
263
+ self,
264
+ hsbk: list[tuple[int, int, int, int]],
265
+ ) -> AnimatorStats:
266
+ """Send a frame to the device via direct UDP.
267
+
268
+ Applies orientation mapping (for matrix devices), updates colors
269
+ in prebaked packets, and sends them directly via UDP. No ACKs,
270
+ no waiting - maximum throughput.
271
+
272
+ This is a synchronous method for minimum overhead. UDP sendto()
273
+ is non-blocking for datagrams.
274
+
275
+ Args:
276
+ hsbk: Protocol-ready HSBK data for all pixels.
277
+ Each tuple is (hue, sat, brightness, kelvin) where
278
+ H/S/B are 0-65535 and K is 1500-9000.
279
+
280
+ Returns:
281
+ AnimatorStats with operation statistics
282
+
283
+ Raises:
284
+ ValueError: If hsbk length doesn't match pixel_count
285
+ """
286
+ start_time = time.perf_counter()
287
+
288
+ # Apply orientation mapping
289
+ device_data = self._framebuffer.apply(hsbk)
290
+
291
+ # Update colors in prebaked templates
292
+ self._packet_generator.update_colors(self._templates, device_data)
293
+
294
+ # Ensure socket exists
295
+ if self._socket is None:
296
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
297
+ self._socket.setblocking(False)
298
+
299
+ # Send each packet, updating sequence number
300
+ for tmpl in self._templates:
301
+ tmpl.data[SEQUENCE_OFFSET] = self._sequence
302
+ self._sequence = (self._sequence + 1) % 256
303
+ self._socket.sendto(tmpl.data, (self._ip, self._port))
304
+
305
+ end_time = time.perf_counter()
306
+
307
+ return AnimatorStats(
308
+ packets_sent=len(self._templates),
309
+ total_time_ms=(end_time - start_time) * 1000,
310
+ )
311
+
312
+ def close(self) -> None:
313
+ """Close the UDP socket.
314
+
315
+ Call this when done with the animator to free resources.
316
+ """
317
+ if self._socket is not None:
318
+ self._socket.close()
319
+ self._socket = None
320
+
321
+ def __del__(self) -> None:
322
+ """Clean up socket on garbage collection."""
323
+ self.close()