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.
@@ -0,0 +1,395 @@
1
+ """Orientation mapping for LIFX matrix device animations.
2
+
3
+ This module provides the FrameBuffer class, which handles pixel coordinate
4
+ remapping for matrix devices based on tile orientation. For animations,
5
+ every frame is assumed to change all pixels, so no diff tracking is performed.
6
+
7
+ Multi-Tile Canvas Support:
8
+ For devices with multiple tiles (e.g., original LIFX Tile with 5 tiles),
9
+ the FrameBuffer creates a unified canvas based on tile positions (user_x,
10
+ user_y). The user provides colors for the entire canvas, and the FrameBuffer
11
+ extracts the appropriate region for each tile.
12
+
13
+ Example with 5 tiles arranged horizontally:
14
+ - Canvas dimensions: 40x8 (5 tiles * 8 pixels wide)
15
+ - User provides 320 HSBK tuples (40*8)
16
+ - FrameBuffer extracts 64 pixels for each tile based on position
17
+
18
+ Design Philosophy:
19
+ Colors are "protocol-ready" HSBK tuples - uint16 values matching the LIFX
20
+ protocol (0-65535 for H/S/B, 1500-9000 for K).
21
+
22
+ Example:
23
+ ```python
24
+ from lifx.animation.framebuffer import FrameBuffer
25
+
26
+ # Create framebuffer for a matrix device
27
+ fb = await FrameBuffer.for_matrix(matrix_device)
28
+
29
+ # For multi-tile devices, check canvas dimensions
30
+ print(f"Canvas: {fb.canvas_width}x{fb.canvas_height}") # e.g., 40x8
31
+
32
+ # Provide colors for the entire canvas
33
+ canvas_colors = [(65535, 65535, 65535, 3500)] * (fb.canvas_width * fb.canvas_height)
34
+ device_order_data = fb.apply(canvas_colors)
35
+ ```
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from dataclasses import dataclass
41
+ from typing import TYPE_CHECKING
42
+
43
+ if TYPE_CHECKING:
44
+ from lifx.devices.matrix import MatrixLight, TileInfo
45
+ from lifx.devices.multizone import MultiZoneLight
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class TileRegion:
50
+ """Region of a tile within the canvas.
51
+
52
+ Attributes:
53
+ x: X offset in canvas coordinates
54
+ y: Y offset in canvas coordinates
55
+ width: Tile width in pixels
56
+ height: Tile height in pixels
57
+ orientation_lut: Lookup table for orientation remapping (optional)
58
+ """
59
+
60
+ x: int
61
+ y: int
62
+ width: int
63
+ height: int
64
+ orientation_lut: tuple[int, ...] | None = None
65
+
66
+
67
+ class FrameBuffer:
68
+ """Orientation mapping for matrix device animations.
69
+
70
+ For matrix devices with tile orientation (like the original LIFX Tile),
71
+ this class remaps pixel coordinates from user-space (logical layout) to
72
+ device-space (physical tile order accounting for rotation).
73
+
74
+ For multi-tile devices, the FrameBuffer creates a unified canvas where
75
+ each tile's position (user_x, user_y) determines which region of the
76
+ canvas it displays. This allows animations to span across all tiles
77
+ instead of being mirrored.
78
+
79
+ For multizone devices and matrix devices without orientation, this is
80
+ essentially a passthrough.
81
+
82
+ Attributes:
83
+ pixel_count: Total number of device pixels
84
+ canvas_width: Width of the logical canvas in pixels
85
+ canvas_height: Height of the logical canvas in pixels
86
+ tile_regions: List of tile regions with positions and orientations
87
+
88
+ Example:
89
+ ```python
90
+ # Create for a device
91
+ fb = await FrameBuffer.for_matrix(matrix_device)
92
+
93
+ # Check canvas dimensions
94
+ print(f"Canvas: {fb.canvas_width}x{fb.canvas_height}")
95
+
96
+ # Provide canvas-sized input
97
+ canvas = [(0, 0, 65535, 3500)] * (fb.canvas_width * fb.canvas_height)
98
+ device_data = fb.apply(canvas)
99
+ ```
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ pixel_count: int,
105
+ canvas_width: int = 0,
106
+ canvas_height: int = 0,
107
+ tile_regions: list[TileRegion] | None = None,
108
+ ) -> None:
109
+ """Initialize framebuffer.
110
+
111
+ Args:
112
+ pixel_count: Total number of device pixels
113
+ canvas_width: Width of the logical canvas (0 = same as pixel_count)
114
+ canvas_height: Height of the logical canvas (0 = 1 for linear)
115
+ tile_regions: List of tile regions with positions and orientations.
116
+ If provided, input is interpreted as a 2D canvas.
117
+ """
118
+ if pixel_count < 0:
119
+ raise ValueError(f"pixel_count must be non-negative, got {pixel_count}")
120
+
121
+ self._pixel_count = pixel_count
122
+ self._tile_regions = tile_regions
123
+
124
+ # Canvas dimensions
125
+ if tile_regions:
126
+ # Calculate from tile regions
127
+ self._canvas_width = canvas_width
128
+ self._canvas_height = canvas_height
129
+ else:
130
+ # Linear (multizone) or single tile
131
+ self._canvas_width = canvas_width if canvas_width > 0 else pixel_count
132
+ self._canvas_height = canvas_height if canvas_height > 0 else 1
133
+
134
+ @classmethod
135
+ async def for_matrix(
136
+ cls,
137
+ device: MatrixLight,
138
+ ) -> FrameBuffer:
139
+ """Create a FrameBuffer configured for a MatrixLight device.
140
+
141
+ Automatically determines pixel count from device chain and creates
142
+ appropriate mapping for tile orientations and positions.
143
+
144
+ For multi-tile devices (has_chain capability), creates a unified canvas
145
+ based on tile positions (user_x, user_y). Each tile's position determines
146
+ which region of the canvas it displays, allowing animations to span
147
+ across all tiles.
148
+
149
+ Args:
150
+ device: MatrixLight device (must be connected)
151
+
152
+ Returns:
153
+ Configured FrameBuffer instance
154
+
155
+ Example:
156
+ ```python
157
+ async with await MatrixLight.from_ip("192.168.1.100") as matrix:
158
+ fb = await FrameBuffer.for_matrix(matrix)
159
+ print(f"Canvas: {fb.canvas_width}x{fb.canvas_height}")
160
+ ```
161
+ """
162
+ # Ensure device chain is loaded
163
+ if device.device_chain is None:
164
+ await device.get_device_chain()
165
+
166
+ tiles = device.device_chain
167
+ if not tiles:
168
+ raise ValueError("Device has no tiles")
169
+
170
+ # Calculate total device pixels
171
+ pixel_count = sum(t.width * t.height for t in tiles)
172
+
173
+ # Ensure capabilities are loaded
174
+ if device.capabilities is None:
175
+ await device._ensure_capabilities()
176
+
177
+ # Only build canvas mapping for devices with chain capability.
178
+ # The original LIFX Tile is the only matrix device with accelerometer-based
179
+ # orientation detection and multi-tile positioning. Other matrix devices
180
+ # (Ceiling, Luna, Candle, Path, etc.) have fixed positions.
181
+ if device.capabilities and device.capabilities.has_chain:
182
+ return cls._for_multi_tile(tiles, pixel_count)
183
+ else:
184
+ # Single tile device - simple passthrough
185
+ first_tile = tiles[0]
186
+ return cls(
187
+ pixel_count=pixel_count,
188
+ canvas_width=first_tile.width,
189
+ canvas_height=first_tile.height,
190
+ )
191
+
192
+ @classmethod
193
+ def _for_multi_tile(
194
+ cls,
195
+ tiles: list[TileInfo],
196
+ pixel_count: int,
197
+ ) -> FrameBuffer:
198
+ """Create FrameBuffer for multi-tile device with canvas positioning.
199
+
200
+ Uses user_x/user_y to determine where each tile sits in the canvas.
201
+ Coordinates are in tile-width units (1.0 = one tile width) and
202
+ represent the center of each tile.
203
+ """
204
+ from lifx.animation.orientation import Orientation, build_orientation_lut
205
+
206
+ if not tiles: # pragma: no cover
207
+ raise ValueError("No tiles provided")
208
+
209
+ first_tile = tiles[0]
210
+ tile_width = first_tile.width
211
+ tile_height = first_tile.height
212
+
213
+ # Convert tile center positions to pixel coordinates
214
+ # user_x/user_y are in "tile width" units, representing tile centers
215
+ tile_centers = [
216
+ (int(round(t.user_x * tile_width)), int(round(t.user_y * tile_height)))
217
+ for t in tiles
218
+ ]
219
+
220
+ # Calculate bounding box of all tile centers
221
+ min_cx = min(c[0] for c in tile_centers)
222
+ max_cx = max(c[0] for c in tile_centers)
223
+ min_cy = min(c[1] for c in tile_centers)
224
+ max_cy = max(c[1] for c in tile_centers)
225
+
226
+ # Canvas extends from leftmost tile left edge to rightmost tile right edge
227
+ # Since centers are at tile_width/2 from edges:
228
+ # - Left edge of leftmost tile: min_cx - tile_width/2
229
+ # - Right edge of rightmost tile: max_cx + tile_width/2
230
+ # Total width = (max_cx - min_cx) + tile_width
231
+ canvas_width = (max_cx - min_cx) + tile_width
232
+ canvas_height = (max_cy - min_cy) + tile_height
233
+
234
+ # Origin offset (to convert tile centers to top-left positions)
235
+ origin_x = min_cx - tile_width // 2
236
+ origin_y = min_cy - tile_height // 2
237
+
238
+ # Build tile regions with canvas-relative positions
239
+ tile_regions: list[TileRegion] = []
240
+ for tile, (cx, cy) in zip(tiles, tile_centers, strict=True):
241
+ # Convert center to top-left, relative to canvas origin
242
+ x = cx - tile_width // 2 - origin_x
243
+ y = cy - tile_height // 2 - origin_y
244
+
245
+ # Build orientation LUT for this tile
246
+ orientation = Orientation.from_string(tile.nearest_orientation)
247
+ lut = build_orientation_lut(tile_width, tile_height, orientation)
248
+
249
+ tile_regions.append(
250
+ TileRegion(
251
+ x=x,
252
+ y=y,
253
+ width=tile_width,
254
+ height=tile_height,
255
+ orientation_lut=lut,
256
+ )
257
+ )
258
+
259
+ return cls(
260
+ pixel_count=pixel_count,
261
+ canvas_width=canvas_width,
262
+ canvas_height=canvas_height,
263
+ tile_regions=tile_regions,
264
+ )
265
+
266
+ @classmethod
267
+ async def for_multizone(
268
+ cls,
269
+ device: MultiZoneLight,
270
+ ) -> FrameBuffer:
271
+ """Create a FrameBuffer configured for a MultiZoneLight device.
272
+
273
+ Automatically determines pixel count from zone count.
274
+ Multizone devices don't need permutation (zones are linear).
275
+
276
+ Args:
277
+ device: MultiZoneLight device (must be connected)
278
+
279
+ Returns:
280
+ Configured FrameBuffer instance
281
+
282
+ Example:
283
+ ```python
284
+ async with await MultiZoneLight.from_ip("192.168.1.100") as strip:
285
+ fb = await FrameBuffer.for_multizone(strip)
286
+ ```
287
+ """
288
+ # Get zone count (fetches from device if not cached)
289
+ zone_count = await device.get_zone_count()
290
+
291
+ return cls(pixel_count=zone_count)
292
+
293
+ @property
294
+ def pixel_count(self) -> int:
295
+ """Get total number of device pixels."""
296
+ return self._pixel_count
297
+
298
+ @property
299
+ def canvas_width(self) -> int:
300
+ """Get width of the logical canvas in pixels."""
301
+ return self._canvas_width
302
+
303
+ @property
304
+ def canvas_height(self) -> int:
305
+ """Get height of the logical canvas in pixels."""
306
+ return self._canvas_height
307
+
308
+ @property
309
+ def canvas_size(self) -> int:
310
+ """Get total number of canvas pixels (width * height)."""
311
+ return self._canvas_width * self._canvas_height
312
+
313
+ @property
314
+ def tile_regions(self) -> list[TileRegion] | None:
315
+ """Get tile regions if configured."""
316
+ return self._tile_regions
317
+
318
+ def apply(
319
+ self, hsbk: list[tuple[int, int, int, int]]
320
+ ) -> list[tuple[int, int, int, int]]:
321
+ """Apply orientation mapping to frame data.
322
+
323
+ For multi-tile devices, the input is interpreted as a row-major 2D
324
+ canvas of size (canvas_width x canvas_height). Each tile extracts
325
+ its region from the canvas based on its position.
326
+
327
+ For single-tile or multizone devices, this is a passthrough.
328
+
329
+ Args:
330
+ hsbk: List of protocol-ready HSBK tuples.
331
+ - For multi-tile: length must match canvas_size
332
+ - For single-tile/multizone: length must match pixel_count
333
+ Each tuple is (hue, sat, brightness, kelvin) where
334
+ H/S/B are 0-65535 and K is 1500-9000.
335
+
336
+ Returns:
337
+ Remapped HSBK data in device order
338
+
339
+ Raises:
340
+ ValueError: If hsbk length doesn't match expected size
341
+ """
342
+ # Multi-tile canvas mode
343
+ if self._tile_regions:
344
+ expected_size = self._canvas_width * self._canvas_height
345
+ if len(hsbk) != expected_size:
346
+ raise ValueError(
347
+ f"HSBK length ({len(hsbk)}) must match "
348
+ f"canvas_size ({expected_size})"
349
+ )
350
+ return self._apply_canvas(hsbk)
351
+
352
+ # Single-tile or multizone mode (passthrough)
353
+ if len(hsbk) != self._pixel_count:
354
+ raise ValueError(
355
+ f"HSBK length ({len(hsbk)}) must match "
356
+ f"pixel_count ({self._pixel_count})"
357
+ )
358
+
359
+ return list(hsbk)
360
+
361
+ def _apply_canvas(
362
+ self, hsbk: list[tuple[int, int, int, int]]
363
+ ) -> list[tuple[int, int, int, int]]:
364
+ """Extract tile regions from canvas and apply orientation.
365
+
366
+ Args:
367
+ hsbk: Row-major canvas data (canvas_width x canvas_height)
368
+
369
+ Returns:
370
+ Device-ordered pixels (concatenated tiles)
371
+ """
372
+ result: list[tuple[int, int, int, int]] = []
373
+ canvas_width = self._canvas_width
374
+
375
+ for region in self._tile_regions: # type: ignore[union-attr]
376
+ # Extract pixels for this tile from the canvas
377
+ tile_pixels: list[tuple[int, int, int, int]] = []
378
+
379
+ for row in range(region.height):
380
+ canvas_y = region.y + row
381
+ for col in range(region.width):
382
+ canvas_x = region.x + col
383
+ canvas_idx = canvas_y * canvas_width + canvas_x
384
+ tile_pixels.append(hsbk[canvas_idx])
385
+
386
+ # Apply orientation remapping for this tile
387
+ if region.orientation_lut:
388
+ tile_pixels = [
389
+ tile_pixels[region.orientation_lut[i]]
390
+ for i in range(len(tile_pixels))
391
+ ]
392
+
393
+ result.extend(tile_pixels)
394
+
395
+ return result
@@ -0,0 +1,159 @@
1
+ """Tile orientation remapping for LIFX matrix devices.
2
+
3
+ This module provides utilities for remapping pixel coordinates based on tile
4
+ orientation, enabling correct display regardless of how tiles are physically
5
+ mounted.
6
+
7
+ LIFX tiles report their orientation via accelerometer data. This module
8
+ converts that orientation into lookup tables for efficient pixel remapping
9
+ during animation.
10
+
11
+ The key insight is that orientation affects how row-major framebuffer indices
12
+ map to physical tile positions. By pre-computing lookup tables (LUTs), we
13
+ can apply orientation correction with a single array lookup per pixel.
14
+
15
+ Example:
16
+ ```python
17
+ from lifx.animation.orientation import Orientation, build_orientation_lut
18
+
19
+ # Build LUT for a single 8x8 tile rotated 90 degrees
20
+ lut = build_orientation_lut(8, 8, Orientation.ROTATED_90)
21
+
22
+ # Apply LUT to remap pixels
23
+ output = [framebuffer[lut[i]] for i in range(len(framebuffer))]
24
+ ```
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from enum import IntEnum
30
+ from functools import lru_cache
31
+
32
+
33
+ class Orientation(IntEnum):
34
+ """Tile orientation based on accelerometer data.
35
+
36
+ These values match the orientation detection in TileInfo.nearest_orientation
37
+ but use integer enum for efficient comparison and caching.
38
+
39
+ Physical mounting positions:
40
+ - RIGHT_SIDE_UP: Normal position, no rotation needed
41
+ - ROTATED_90: Rotated 90 degrees clockwise (RotatedRight)
42
+ - ROTATED_180: Upside down (UpsideDown)
43
+ - ROTATED_270: Rotated 90 degrees counter-clockwise (RotatedLeft)
44
+ - FACE_UP: Tile facing ceiling
45
+ - FACE_DOWN: Tile facing floor
46
+ """
47
+
48
+ RIGHT_SIDE_UP = 0 # "Upright"
49
+ ROTATED_90 = 1 # "RotatedRight"
50
+ ROTATED_180 = 2 # "UpsideDown"
51
+ ROTATED_270 = 3 # "RotatedLeft"
52
+ FACE_UP = 4 # "FaceUp"
53
+ FACE_DOWN = 5 # "FaceDown"
54
+
55
+ @classmethod
56
+ def from_string(cls, orientation_str: str) -> Orientation:
57
+ """Convert TileInfo.nearest_orientation string to Orientation enum.
58
+
59
+ Args:
60
+ orientation_str: String from TileInfo.nearest_orientation
61
+
62
+ Returns:
63
+ Corresponding Orientation enum value
64
+
65
+ Raises:
66
+ ValueError: If orientation string is not recognized
67
+ """
68
+ mapping = {
69
+ "Upright": cls.RIGHT_SIDE_UP,
70
+ "RotatedRight": cls.ROTATED_90,
71
+ "UpsideDown": cls.ROTATED_180,
72
+ "RotatedLeft": cls.ROTATED_270,
73
+ "FaceUp": cls.FACE_UP,
74
+ "FaceDown": cls.FACE_DOWN,
75
+ }
76
+ if orientation_str not in mapping:
77
+ raise ValueError(f"Unknown orientation: {orientation_str}")
78
+ return mapping[orientation_str]
79
+
80
+
81
+ @lru_cache(maxsize=64)
82
+ def build_orientation_lut(
83
+ width: int,
84
+ height: int,
85
+ orientation: Orientation,
86
+ ) -> tuple[int, ...]:
87
+ """Build a lookup table for remapping pixels based on tile orientation.
88
+
89
+ The LUT maps physical tile positions to row-major framebuffer indices.
90
+ For a pixel at physical position i, lut[i] gives the framebuffer index.
91
+
92
+ This is LRU-cached because tiles typically have standard dimensions (8x8)
93
+ and there are only 6 orientations, so the cache will be highly effective.
94
+
95
+ Args:
96
+ width: Tile width in pixels
97
+ height: Tile height in pixels
98
+ orientation: Tile orientation
99
+
100
+ Returns:
101
+ Tuple of indices mapping physical position to framebuffer position.
102
+ Tuple is used instead of list for hashability in caches.
103
+
104
+ Example:
105
+ >>> lut = build_orientation_lut(8, 8, Orientation.RIGHT_SIDE_UP)
106
+ >>> len(lut)
107
+ 64
108
+ >>> lut[0] # First pixel maps to index 0
109
+ 0
110
+ >>> lut = build_orientation_lut(8, 8, Orientation.ROTATED_180)
111
+ >>> lut[0] # First physical position maps to last framebuffer index
112
+ 63
113
+ """
114
+ size = width * height
115
+ lut: list[int] = [0] * size
116
+
117
+ for y in range(height):
118
+ for x in range(width):
119
+ # Physical position in row-major order
120
+ physical_idx = y * width + x
121
+
122
+ # Calculate source position based on orientation
123
+ if orientation == Orientation.RIGHT_SIDE_UP:
124
+ # No transformation
125
+ src_x, src_y = x, y
126
+ elif orientation == Orientation.ROTATED_90:
127
+ # 90 degrees clockwise: (x, y) -> (height - 1 - y, x)
128
+ # Note: Only valid for square tiles. Non-square tiles would require
129
+ # a source buffer with swapped dimensions (e.g., 5x7 for a 7x5 tile).
130
+ # For non-square tiles, fall back to identity transformation.
131
+ if width == height:
132
+ src_x = height - 1 - y
133
+ src_y = x
134
+ else:
135
+ src_x, src_y = x, y
136
+ elif orientation == Orientation.ROTATED_180:
137
+ # 180 degrees: (x, y) -> (width - 1 - x, height - 1 - y)
138
+ # Works for both square and non-square tiles
139
+ src_x = width - 1 - x
140
+ src_y = height - 1 - y
141
+ elif orientation == Orientation.ROTATED_270:
142
+ # 270 degrees (90 counter-clockwise): (x, y) -> (y, width - 1 - x)
143
+ # Note: Only valid for square tiles. For non-square tiles,
144
+ # fall back to identity transformation.
145
+ if width == height:
146
+ src_x = y
147
+ src_y = width - 1 - x
148
+ else:
149
+ src_x, src_y = x, y
150
+ else:
151
+ # FACE_UP and FACE_DOWN: treat as right-side-up (no x/y rotation)
152
+ # The z-axis orientation doesn't affect 2D pixel mapping
153
+ src_x, src_y = x, y
154
+
155
+ # Source index in row-major order
156
+ src_idx = src_y * width + src_x
157
+ lut[physical_idx] = src_idx
158
+
159
+ return tuple(lut)