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 +4 -0
- lifx/animation/__init__.py +87 -0
- lifx/animation/animator.py +323 -0
- lifx/animation/framebuffer.py +395 -0
- lifx/animation/orientation.py +159 -0
- lifx/animation/packets.py +497 -0
- lifx/api.py +0 -8
- lifx/const.py +18 -0
- lifx/devices/ceiling.py +7 -8
- lifx/devices/matrix.py +2 -19
- lifx/effects/colorloop.py +2 -2
- lifx/network/connection.py +8 -7
- lifx/network/discovery.py +3 -3
- lifx/network/mdns/transport.py +2 -2
- lifx/network/transport.py +8 -3
- lifx/network/utils.py +15 -0
- lifx/protocol/serializer.py +0 -85
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/METADATA +1 -1
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/RECORD +21 -15
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/WHEEL +0 -0
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/licenses/LICENSE +0 -0
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()
|