lifx-emulator 2.2.1__py3-none-any.whl → 2.3.1__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_emulator/__main__.py CHANGED
@@ -286,9 +286,9 @@ async def run(
286
286
  tile: Number of tile/matrix chain devices.
287
287
  tile_count: Number of tiles per device. Uses product defaults if not
288
288
  specified (5 for Tile, 1 for Candle/Ceiling).
289
- tile_width: Width of each tile in pixels. Uses product defaults if not
289
+ tile_width: Width of each tile in zones. Uses product defaults if not
290
290
  specified (8 for most devices).
291
- tile_height: Height of each tile in pixels. Uses product defaults if
291
+ tile_height: Height of each tile in zones. Uses product defaults if
292
292
  not specified (8 for most devices).
293
293
  serial_prefix: Serial number prefix as 6 hex characters.
294
294
  serial_start: Starting serial suffix for auto-incrementing device serials.
@@ -25,10 +25,10 @@ class DeviceCreateRequest(BaseModel):
25
25
  None, description="Number of tiles for matrix devices", ge=0, le=100
26
26
  )
27
27
  tile_width: int | None = Field(
28
- None, description="Width of each tile in pixels", ge=1, le=256
28
+ None, description="Width of each tile in zones", ge=1, le=256
29
29
  )
30
30
  tile_height: int | None = Field(
31
- None, description="Height of each tile in pixels", ge=1, le=256
31
+ None, description="Height of each tile in zones", ge=1, le=256
32
32
  )
33
33
  firmware_major: int | None = Field(
34
34
  None, description="Firmware major version", ge=0, le=255
@@ -163,7 +163,7 @@
163
163
  gap: 2px;
164
164
  margin-top: 4px;
165
165
  }
166
- .tile-pixel {
166
+ .tile-zone {
167
167
  width: 8px;
168
168
  height: 8px;
169
169
  border-radius: 1px;
@@ -644,7 +644,7 @@
644
644
  `;
645
645
  } else if (dev.has_matrix && dev.tile_devices &&
646
646
  dev.tile_devices.length > 0) {
647
- // Render actual tile pixels
647
+ // Render actual tile zones
648
648
  const tilesHtml = dev.tile_devices.map((tile, tileIndex) => {
649
649
  if (!tile.colors || tile.colors.length === 0) {
650
650
  return '<div style="color: #666;">No color data</div>';
@@ -652,14 +652,14 @@
652
652
 
653
653
  const width = tile.width || 8;
654
654
  const height = tile.height || 8;
655
- const totalPixels = width * height;
655
+ const totalzones = width * height;
656
656
 
657
- // Create grid of pixels
658
- const slicedColors = tile.colors.slice(0, totalPixels);
659
- const pixelsHtml = slicedColors.map(color => {
657
+ // Create grid of zones
658
+ const slicedColors = tile.colors.slice(0, totalzones);
659
+ const zonesHtml = slicedColors.map(color => {
660
660
  const rgb = hsbkToRgb(color);
661
661
  const bgStyle = `background: ${rgb};`;
662
- return `<div class="tile-pixel" style="${bgStyle}"></div>`;
662
+ return `<div class="tile-zone" style="${bgStyle}"></div>`;
663
663
  }).join('');
664
664
 
665
665
  const labelStyle = (
@@ -675,7 +675,7 @@
675
675
  T${tileIndex + 1}
676
676
  </div>
677
677
  <div class="tile-grid" style="${gridStyle}">
678
- ${pixelsHtml}
678
+ ${zonesHtml}
679
679
  </div>
680
680
  </div>
681
681
  `;
@@ -9,7 +9,7 @@ import time
9
9
  from typing import Any
10
10
 
11
11
  from lifx_emulator.constants import LIFX_HEADER_SIZE
12
- from lifx_emulator.devices.states import DeviceState
12
+ from lifx_emulator.devices.states import DeviceState, TileFramebuffers
13
13
  from lifx_emulator.handlers import HandlerRegistry, create_default_registry
14
14
  from lifx_emulator.protocol.header import LifxHeader
15
15
  from lifx_emulator.protocol.packets import (
@@ -90,10 +90,10 @@ class EmulatedLifxDevice:
90
90
  if self.state.has_matrix and self.state.tile_count > 0:
91
91
  if not self.state.tile_devices:
92
92
  for i in range(self.state.tile_count):
93
- pixels = self.state.tile_width * self.state.tile_height
93
+ zones = self.state.tile_width * self.state.tile_height
94
94
  tile_colors = [
95
95
  LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
96
- for _ in range(pixels)
96
+ for _ in range(zones)
97
97
  ]
98
98
 
99
99
  self.state.tile_devices.append(
@@ -114,6 +114,12 @@ class EmulatedLifxDevice:
114
114
  }
115
115
  )
116
116
 
117
+ # Initialize framebuffer storage for each tile (framebuffers 1-7)
118
+ # Framebuffer 0 is stored in tile_devices[i]["colors"]
119
+ if not self.state.tile_framebuffers:
120
+ for i in range(self.state.tile_count):
121
+ self.state.tile_framebuffers.append(TileFramebuffers(tile_index=i))
122
+
117
123
  # Save initial state if persistence is enabled
118
124
  # This ensures newly created devices are immediately persisted
119
125
  if self.storage:
@@ -97,6 +97,17 @@ def serialize_device_state(device_state: Any) -> dict[str, Any]:
97
97
  }
98
98
  for t in device_state.tile_devices
99
99
  ]
100
+ # Serialize tile framebuffers (non-visible framebuffers 1-7)
101
+ state_dict["tile_framebuffers"] = [
102
+ {
103
+ "tile_index": fb.tile_index,
104
+ "framebuffers": {
105
+ str(fb_idx): [serialize_hsbk(c) for c in colors]
106
+ for fb_idx, colors in fb.framebuffers.items()
107
+ },
108
+ }
109
+ for fb in device_state.tile_framebuffers
110
+ ]
100
111
 
101
112
  return state_dict
102
113
 
@@ -127,4 +138,20 @@ def deserialize_device_state(state_dict: dict[str, Any]) -> dict[str, Any]:
127
138
  for tile_dict in state_dict["tile_devices"]:
128
139
  tile_dict["colors"] = [deserialize_hsbk(c) for c in tile_dict["colors"]]
129
140
 
141
+ # Deserialize tile framebuffers if present (for backwards compatibility)
142
+ if "tile_framebuffers" in state_dict:
143
+ from lifx_emulator.devices.states import TileFramebuffers
144
+
145
+ deserialized_fbs = []
146
+ for fb_dict in state_dict["tile_framebuffers"]:
147
+ tile_fb = TileFramebuffers(tile_index=fb_dict["tile_index"])
148
+ # Deserialize each framebuffer's colors
149
+ for fb_idx_str, colors_list in fb_dict["framebuffers"].items():
150
+ fb_idx = int(fb_idx_str)
151
+ tile_fb.framebuffers[fb_idx] = [
152
+ deserialize_hsbk(c) for c in colors_list
153
+ ]
154
+ deserialized_fbs.append(tile_fb)
155
+ state_dict["tile_framebuffers"] = deserialized_fbs
156
+
130
157
  return state_dict
@@ -82,6 +82,32 @@ class MultiZoneState:
82
82
  effect_speed: int = 5 # Duration of one cycle in seconds
83
83
 
84
84
 
85
+ @dataclass
86
+ class TileFramebuffers:
87
+ """Internal storage for non-visible tile framebuffers (1-7).
88
+
89
+ Framebuffer 0 is stored in tile_devices[i]["colors"] (the visible buffer).
90
+ Framebuffers 1-7 are stored here for Set64/CopyFrameBuffer operations.
91
+ Each framebuffer is a list of LightHsbk colors with length = width * height.
92
+ """
93
+
94
+ tile_index: int # Which tile this belongs to
95
+ framebuffers: dict[int, list[LightHsbk]] = field(default_factory=dict)
96
+
97
+ def get_framebuffer(
98
+ self, fb_index: int, width: int, height: int
99
+ ) -> list[LightHsbk]:
100
+ """Get framebuffer by index, creating it if needed."""
101
+ if fb_index not in self.framebuffers:
102
+ # Initialize with default black color
103
+ zones = width * height
104
+ self.framebuffers[fb_index] = [
105
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
106
+ for _ in range(zones)
107
+ ]
108
+ return self.framebuffers[fb_index]
109
+
110
+
85
111
  @dataclass
86
112
  class MatrixState:
87
113
  """Matrix (tile/candle) capability state."""
@@ -101,6 +127,9 @@ class MatrixState:
101
127
  effect_cloud_sat_max: int = (
102
128
  0 # Max cloud saturation 0-200 (only when effect_type=5)
103
129
  )
130
+ # Internal storage for non-visible framebuffers (1-7) per tile
131
+ # Framebuffer 0 remains in tile_devices[i]["colors"]
132
+ tile_framebuffers: list[TileFramebuffers] = field(default_factory=list)
104
133
 
105
134
 
106
135
  @dataclass
@@ -215,6 +244,7 @@ class DeviceState:
215
244
  "tile_effect_sky_type": ("matrix", "effect_sky_type"),
216
245
  "tile_effect_cloud_sat_min": ("matrix", "effect_cloud_sat_min"),
217
246
  "tile_effect_cloud_sat_max": ("matrix", "effect_cloud_sat_max"),
247
+ "tile_framebuffers": "matrix",
218
248
  }
219
249
 
220
250
  # Default values for optional state attributes when state object is None
@@ -240,6 +270,7 @@ class DeviceState:
240
270
  "tile_effect_sky_type": 0,
241
271
  "tile_effect_cloud_sat_min": 0,
242
272
  "tile_effect_cloud_sat_max": 0,
273
+ "tile_framebuffers": [],
243
274
  }
244
275
 
245
276
  def get_target_bytes(self) -> bytes:
@@ -136,8 +136,8 @@ class DeviceBuilder:
136
136
  """Set tile dimensions for matrix devices.
137
137
 
138
138
  Args:
139
- width: Tile width in pixels
140
- height: Tile height in pixels
139
+ width: Tile width in zones
140
+ height: Tile height in zones
141
141
 
142
142
  Returns:
143
143
  Self for method chaining
@@ -104,8 +104,8 @@ def create_tile_device(
104
104
  Args:
105
105
  serial: Optional serial
106
106
  tile_count: Optional tile count (uses product default)
107
- tile_width: Optional tile width in pixels (uses product default)
108
- tile_height: Optional tile height in pixels (uses product default)
107
+ tile_width: Optional tile width in zones (uses product default)
108
+ tile_height: Optional tile height in zones (uses product default)
109
109
  firmware_version: Optional firmware version tuple (major, minor)
110
110
  storage: Optional storage for persistence
111
111
  scenario_manager: Optional scenario manager
@@ -164,8 +164,8 @@ def create_device(
164
164
  zone_count: Number of zones for multizone devices (auto-determined)
165
165
  extended_multizone: Enable extended multizone requests
166
166
  tile_count: Number of tiles for matrix devices (default: 5)
167
- tile_width: Width of each tile in pixels (default: 8)
168
- tile_height: Height of each tile in pixels (default: 8)
167
+ tile_width: Width of each tile in zones (default: 8)
168
+ tile_height: Height of each tile in zones (default: 8)
169
169
  firmware_version: Optional firmware version tuple (major, minor).
170
170
  If not specified, uses 3.70 for extended_multizone
171
171
  or 2.60 otherwise
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from lifx_emulator.handlers.base import PacketHandler
9
9
  from lifx_emulator.protocol.packets import Light
10
- from lifx_emulator.protocol.protocol_types import LightLastHevCycleResult
10
+ from lifx_emulator.protocol.protocol_types import LightHsbk, LightLastHevCycleResult
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from lifx_emulator.devices import DeviceState
@@ -15,6 +15,72 @@ if TYPE_CHECKING:
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
+ def _compute_average_color(colors: list[LightHsbk]) -> LightHsbk:
19
+ """Compute average HSBK color from a list of LightHsbk colors.
20
+
21
+ Uses circular mean for hue to correctly handle hue wraparound
22
+ (e.g., average of 10° and 350° is 0°, not 180°).
23
+
24
+ Args:
25
+ colors: List of LightHsbk colors to average
26
+
27
+ Returns:
28
+ LightHsbk with averaged values using circular mean for hue
29
+ """
30
+ import math
31
+
32
+ if not colors:
33
+ return LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
34
+
35
+ # Convert uint16 values to proper ranges and calculate circular mean
36
+ hue_x_total = 0.0
37
+ hue_y_total = 0.0
38
+ saturation_total = 0.0
39
+ brightness_total = 0.0
40
+ kelvin_total = 0
41
+
42
+ for color in colors:
43
+ # Convert uint16 hue (0-65535) to degrees (0-360)
44
+ hue_deg = round(float(color.hue) * 360 / 0x10000, 2)
45
+
46
+ # Convert uint16 sat/bright (0-65535) to float (0-1)
47
+ sat_float = round(float(color.saturation) / 0xFFFF, 4)
48
+ bright_float = round(float(color.brightness) / 0xFFFF, 4)
49
+
50
+ # Circular mean calculation for hue using sin/cos
51
+ hue_x_total += math.sin(hue_deg * 2.0 * math.pi / 360)
52
+ hue_y_total += math.cos(hue_deg * 2.0 * math.pi / 360)
53
+
54
+ # Regular sums for other components
55
+ saturation_total += sat_float
56
+ brightness_total += bright_float
57
+ kelvin_total += color.kelvin
58
+
59
+ # Calculate circular mean for hue
60
+ hue = math.atan2(hue_x_total, hue_y_total) / (2.0 * math.pi)
61
+ if hue < 0.0:
62
+ hue += 1.0
63
+ hue *= 360
64
+ hue = round(hue, 4)
65
+
66
+ # Calculate arithmetic means for other components
67
+ saturation = round(saturation_total / len(colors), 4)
68
+ brightness = round(brightness_total / len(colors), 4)
69
+ kelvin = round(kelvin_total / len(colors))
70
+
71
+ # Convert back to uint16 values
72
+ uint16_hue = int(round(0x10000 * hue) / 360) % 0x10000
73
+ uint16_saturation = int(round(0xFFFF * saturation))
74
+ uint16_brightness = int(round(0xFFFF * brightness))
75
+
76
+ return LightHsbk(
77
+ hue=uint16_hue,
78
+ saturation=uint16_saturation,
79
+ brightness=uint16_brightness,
80
+ kelvin=kelvin,
81
+ )
82
+
83
+
18
84
  class GetColorHandler(PacketHandler):
19
85
  """Handle LightGet (101) -> LightState (107)."""
20
86
 
@@ -23,9 +89,24 @@ class GetColorHandler(PacketHandler):
23
89
  def handle(
24
90
  self, device_state: DeviceState, packet: Any | None, res_required: bool
25
91
  ) -> list[Any]:
92
+ # For multizone/matrix devices, compute average color from all zones
93
+ # This provides backwards compatibility with clients that don't use
94
+ # zone-specific or tile-specific packets
95
+ color_to_return = device_state.color
96
+
97
+ if device_state.has_multizone and device_state.zone_colors:
98
+ # Return average of all zone colors
99
+ color_to_return = _compute_average_color(device_state.zone_colors)
100
+ elif device_state.has_matrix and device_state.tile_devices:
101
+ # Collect all zone colors from all tiles
102
+ all_zones = []
103
+ for tile in device_state.tile_devices:
104
+ all_zones.extend(tile["colors"])
105
+ color_to_return = _compute_average_color(all_zones)
106
+
26
107
  return [
27
108
  Light.StateColor(
28
- color=device_state.color,
109
+ color=color_to_return,
29
110
  power=device_state.power_level,
30
111
  label=device_state.label,
31
112
  )
@@ -46,10 +127,36 @@ class SetColorHandler(PacketHandler):
46
127
  if packet:
47
128
  device_state.color = packet.color
48
129
  c = packet.color
49
- logger.info(
50
- f"Color set to HSBK({c.hue}, {c.saturation}, "
51
- f"{c.brightness}, {c.kelvin}), duration={packet.duration}ms"
52
- )
130
+
131
+ # For backwards compatibility: propagate color to all zones
132
+ # Multizone devices: update all zone colors
133
+ if device_state.has_multizone and device_state.zone_colors:
134
+ for i in range(len(device_state.zone_colors)):
135
+ device_state.zone_colors[i] = packet.color
136
+ logger.info(
137
+ f"Color set to HSBK({c.hue}, {c.saturation}, "
138
+ f"{c.brightness}, {c.kelvin}) across all "
139
+ f"{len(device_state.zone_colors)} zones, "
140
+ f"duration={packet.duration}ms"
141
+ )
142
+ # Matrix devices: update all tile zones
143
+ elif device_state.has_matrix and device_state.tile_devices:
144
+ total_zones = 0
145
+ for tile in device_state.tile_devices:
146
+ for i in range(len(tile["colors"])):
147
+ tile["colors"][i] = packet.color
148
+ total_zones += len(tile["colors"])
149
+ logger.info(
150
+ f"Color set to HSBK({c.hue}, {c.saturation}, "
151
+ f"{c.brightness}, {c.kelvin}) across all {total_zones} zones, "
152
+ f"duration={packet.duration}ms"
153
+ )
154
+ else:
155
+ # Simple color device
156
+ logger.info(
157
+ f"Color set to HSBK({c.hue}, {c.saturation}, "
158
+ f"{c.brightness}, {c.kelvin}), duration={packet.duration}ms"
159
+ )
53
160
 
54
161
  if res_required:
55
162
  return [
@@ -120,6 +227,17 @@ class SetWaveformHandler(PacketHandler):
120
227
  if not packet.transient:
121
228
  device_state.color = packet.color
122
229
 
230
+ # For backwards compatibility: propagate color to all zones
231
+ # Multizone devices: update all zone colors
232
+ if device_state.has_multizone and device_state.zone_colors:
233
+ for i in range(len(device_state.zone_colors)):
234
+ device_state.zone_colors[i] = packet.color
235
+ # Matrix devices: update all tile zones
236
+ elif device_state.has_matrix and device_state.tile_devices:
237
+ for tile in device_state.tile_devices:
238
+ for i in range(len(tile["colors"])):
239
+ tile["colors"][i] = packet.color
240
+
123
241
  logger.info(
124
242
  f"Waveform set: type={packet.waveform}, "
125
243
  f"transient={packet.transient}, period={packet.period}ms, "
@@ -127,13 +245,9 @@ class SetWaveformHandler(PacketHandler):
127
245
  )
128
246
 
129
247
  if res_required:
130
- return [
131
- Light.StateColor(
132
- color=device_state.color,
133
- power=device_state.power_level,
134
- label=device_state.label,
135
- )
136
- ]
248
+ # Use GetColorHandler to get proper averaged color if needed
249
+ handler = GetColorHandler()
250
+ return handler.handle(device_state, None, res_required)
137
251
  return []
138
252
 
139
253
 
@@ -168,6 +282,31 @@ class SetWaveformOptionalHandler(PacketHandler):
168
282
  if packet.set_kelvin:
169
283
  device_state.color.kelvin = packet.color.kelvin
170
284
 
285
+ # Backwards compatibility propagates color changes to zones
286
+ # Multizone devices: update all zone colors
287
+ if device_state.has_multizone and device_state.zone_colors:
288
+ for zone_color in device_state.zone_colors:
289
+ if packet.set_hue:
290
+ zone_color.hue = packet.color.hue
291
+ if packet.set_saturation:
292
+ zone_color.saturation = packet.color.saturation
293
+ if packet.set_brightness:
294
+ zone_color.brightness = packet.color.brightness
295
+ if packet.set_kelvin:
296
+ zone_color.kelvin = packet.color.kelvin
297
+ # Matrix devices: update all tile zones
298
+ elif device_state.has_matrix and device_state.tile_devices:
299
+ for tile in device_state.tile_devices:
300
+ for zone_color in tile["colors"]:
301
+ if packet.set_hue:
302
+ zone_color.hue = packet.color.hue
303
+ if packet.set_saturation:
304
+ zone_color.saturation = packet.color.saturation
305
+ if packet.set_brightness:
306
+ zone_color.brightness = packet.color.brightness
307
+ if packet.set_kelvin:
308
+ zone_color.kelvin = packet.color.kelvin
309
+
171
310
  # Store the waveform color (all components)
172
311
  device_state.waveform_color = packet.color
173
312
 
@@ -180,13 +319,9 @@ class SetWaveformOptionalHandler(PacketHandler):
180
319
  )
181
320
 
182
321
  if res_required:
183
- return [
184
- Light.StateColor(
185
- color=device_state.color,
186
- power=device_state.power_level,
187
- label=device_state.label,
188
- )
189
- ]
322
+ # Use GetColorHandler to get proper averaged color if needed
323
+ handler = GetColorHandler()
324
+ return handler.handle(device_state, None, res_required)
190
325
  return []
191
326
 
192
327
 
@@ -12,6 +12,7 @@ from lifx_emulator.protocol.protocol_types import (
12
12
  DeviceStateVersion,
13
13
  LightHsbk,
14
14
  TileAccelMeas,
15
+ TileBufferRect,
15
16
  TileEffectParameter,
16
17
  TileEffectSettings,
17
18
  TileEffectType,
@@ -137,13 +138,17 @@ class Get64Handler(PacketHandler):
137
138
  tile_width = tile["width"]
138
139
  tile_height = tile["height"]
139
140
 
140
- # Calculate how many rows fit in 64 pixels
141
+ # Get64 always returns framebuffer 0 (the visible buffer)
142
+ # regardless of which fb_index is in the request
143
+ tile_colors = tile["colors"]
144
+
145
+ # Calculate how many rows fit in 64 zones
141
146
  rows_to_return = 64 // rect.width if rect.width > 0 else 1
142
147
  rows_to_return = min(rows_to_return, tile_height - rect.y)
143
148
 
144
149
  # Extract colors from the requested rectangle
145
150
  colors = []
146
- pixels_extracted = 0
151
+ zones_extracted = 0
147
152
 
148
153
  for row in range(rows_to_return):
149
154
  y = rect.y + row
@@ -152,28 +157,35 @@ class Get64Handler(PacketHandler):
152
157
 
153
158
  for col in range(rect.width):
154
159
  x = rect.x + col
155
- if x >= tile_width or pixels_extracted >= 64:
160
+ if x >= tile_width or zones_extracted >= 64:
156
161
  colors.append(
157
162
  LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
158
163
  )
159
- pixels_extracted += 1
164
+ zones_extracted += 1
160
165
  continue
161
166
 
162
- # Calculate pixel index in flat color array
163
- pixel_idx = y * tile_width + x
164
- if pixel_idx < len(tile["colors"]):
165
- colors.append(tile["colors"][pixel_idx])
167
+ # Calculate zone index in flat color array
168
+ zone_idx = y * tile_width + x
169
+ if zone_idx < len(tile_colors):
170
+ colors.append(tile_colors[zone_idx])
166
171
  else:
167
172
  colors.append(
168
173
  LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
169
174
  )
170
- pixels_extracted += 1
175
+ zones_extracted += 1
171
176
 
172
177
  # Pad to exactly 64 colors
173
178
  while len(colors) < 64:
174
179
  colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
175
180
 
176
- return [Tile.State64(tile_index=tile_index, rect=rect, colors=colors)]
181
+ # Return with fb_index forced to 0 (visible buffer)
182
+ return_rect = TileBufferRect(
183
+ fb_index=0, # Always return FB0
184
+ x=rect.x,
185
+ y=rect.y,
186
+ width=rect.width,
187
+ )
188
+ return [Tile.State64(tile_index=tile_index, rect=return_rect, colors=colors)]
177
189
 
178
190
 
179
191
  class Set64Handler(PacketHandler):
@@ -188,16 +200,58 @@ class Set64Handler(PacketHandler):
188
200
  return []
189
201
 
190
202
  tile_index = packet.tile_index
203
+ fb_index = packet.rect.fb_index
191
204
 
192
- if tile_index < len(device_state.tile_devices):
193
- # Update colors from packet
194
- for i, color in enumerate(packet.colors[:64]):
195
- if i < len(device_state.tile_devices[tile_index]["colors"]):
196
- device_state.tile_devices[tile_index]["colors"][i] = color
205
+ if tile_index >= len(device_state.tile_devices):
206
+ return []
197
207
 
198
- logger.info(
199
- f"Tile {tile_index} set 64 colors, duration={packet.duration}ms"
200
- )
208
+ tile = device_state.tile_devices[tile_index]
209
+ tile_width = tile["width"]
210
+ tile_height = tile["height"]
211
+ rect = packet.rect
212
+
213
+ # Determine which framebuffer to update
214
+ if fb_index == 0:
215
+ # Update visible framebuffer (stored in tile_devices)
216
+ target_colors = tile["colors"]
217
+ else:
218
+ # Update non-visible framebuffer (stored in tile_framebuffers)
219
+ if tile_index < len(device_state.tile_framebuffers):
220
+ fb_storage = device_state.tile_framebuffers[tile_index]
221
+ target_colors = fb_storage.get_framebuffer(
222
+ fb_index, tile_width, tile_height
223
+ )
224
+ else:
225
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
226
+ return []
227
+
228
+ # Update colors in the specified rectangle
229
+ # Calculate how many rows fit in 64 zones
230
+ rows_to_write = 64 // rect.width if rect.width > 0 else 1
231
+ rows_to_write = min(rows_to_write, tile_height - rect.y)
232
+
233
+ zones_written = 0
234
+ for row in range(rows_to_write):
235
+ y = rect.y + row
236
+ if y >= tile_height:
237
+ break
238
+
239
+ for col in range(rect.width):
240
+ x = rect.x + col
241
+ if x >= tile_width or zones_written >= 64:
242
+ zones_written += 1
243
+ continue
244
+
245
+ # Calculate zone index in flat color array
246
+ zone_idx = y * tile_width + x
247
+ if zone_idx < len(target_colors) and zones_written < len(packet.colors):
248
+ target_colors[zone_idx] = packet.colors[zones_written]
249
+ zones_written += 1
250
+
251
+ logger.info(
252
+ f"Tile {tile_index} FB{fb_index} set {zones_written} colors at "
253
+ f"({rect.x},{rect.y}), duration={packet.duration}ms"
254
+ )
201
255
 
202
256
  # Tiles never return a response to Set64 regardless of res_required
203
257
  # https://lan.developer.lifx.com/docs/changing-a-device#set64---packet-715
@@ -212,12 +266,83 @@ class CopyFrameBufferHandler(PacketHandler):
212
266
  def handle(
213
267
  self, device_state: DeviceState, packet: Any | None, res_required: bool
214
268
  ) -> list[Any]:
215
- if not device_state.has_matrix:
269
+ if not device_state.has_matrix or not packet:
216
270
  return []
217
271
 
218
- logger.debug("Tile copy frame buffer command received (no-op in emulator)")
219
- # In a real device, this would copy the frame buffer to display
220
- # In emulator, we don't need to do anything special
272
+ tile_index = packet.tile_index
273
+ if tile_index >= len(device_state.tile_devices):
274
+ return []
275
+
276
+ tile = device_state.tile_devices[tile_index]
277
+ tile_width = tile["width"]
278
+ tile_height = tile["height"]
279
+
280
+ src_fb_index = packet.src_fb_index
281
+ dst_fb_index = packet.dst_fb_index
282
+
283
+ # Get source framebuffer
284
+ if src_fb_index == 0:
285
+ src_colors = tile["colors"]
286
+ else:
287
+ if tile_index < len(device_state.tile_framebuffers):
288
+ fb_storage = device_state.tile_framebuffers[tile_index]
289
+ src_colors = fb_storage.get_framebuffer(
290
+ src_fb_index, tile_width, tile_height
291
+ )
292
+ else:
293
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
294
+ return []
295
+
296
+ # Get destination framebuffer
297
+ if dst_fb_index == 0:
298
+ dst_colors = tile["colors"]
299
+ else:
300
+ if tile_index < len(device_state.tile_framebuffers):
301
+ fb_storage = device_state.tile_framebuffers[tile_index]
302
+ dst_colors = fb_storage.get_framebuffer(
303
+ dst_fb_index, tile_width, tile_height
304
+ )
305
+ else:
306
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
307
+ return []
308
+
309
+ # Copy the specified rectangle from source to destination
310
+ src_x = packet.src_x
311
+ src_y = packet.src_y
312
+ dst_x = packet.dst_x
313
+ dst_y = packet.dst_y
314
+ width = packet.width
315
+ height = packet.height
316
+
317
+ zones_copied = 0
318
+ for row in range(height):
319
+ src_row = src_y + row
320
+ dst_row = dst_y + row
321
+
322
+ if src_row >= tile_height or dst_row >= tile_height:
323
+ break
324
+
325
+ for col in range(width):
326
+ src_col = src_x + col
327
+ dst_col = dst_x + col
328
+
329
+ if src_col >= tile_width or dst_col >= tile_width:
330
+ continue
331
+
332
+ src_idx = src_row * tile_width + src_col
333
+ dst_idx = dst_row * tile_width + dst_col
334
+
335
+ if src_idx < len(src_colors) and dst_idx < len(dst_colors):
336
+ dst_colors[dst_idx] = src_colors[src_idx]
337
+ zones_copied += 1
338
+
339
+ logger.info(
340
+ f"Tile {tile_index} copied {zones_copied} zones from "
341
+ f"FB{src_fb_index}({src_x},{src_y}) to "
342
+ f"FB{dst_fb_index}({dst_x},{dst_y}), "
343
+ f"size={width}x{height}, duration={packet.duration}ms"
344
+ )
345
+
221
346
  return []
222
347
 
223
348
 
@@ -806,8 +806,8 @@ def _generate_yaml_header() -> list[str]:
806
806
  "# default_tile_count: <number> # Default number of tiles in chain",
807
807
  "# min_tile_count: <number> # Minimum tiles supported",
808
808
  "# max_tile_count: <number> # Maximum tiles supported",
809
- "# tile_width: <number> # Width of each tile in pixels",
810
- "# tile_height: <number> # Height of each tile in pixels",
809
+ "# tile_width: <number> # Width of each tile in zones",
810
+ "# tile_height: <number> # Height of each tile in zones",
811
811
  "#",
812
812
  "# # Host firmware version (optional, overrides auto firmware selection)",
813
813
  "# default_firmware_major: <number> # Firmware major version (e.g., 3)",
@@ -25,8 +25,8 @@ class ProductSpecs:
25
25
  default_tile_count: Default number of tiles in chain
26
26
  min_tile_count: Minimum tiles supported
27
27
  max_tile_count: Maximum tiles supported
28
- tile_width: Width of each tile in pixels
29
- tile_height: Height of each tile in pixels
28
+ tile_width: Width of each tile in zones
29
+ tile_height: Height of each tile in zones
30
30
  default_firmware_major: Default firmware major version
31
31
  default_firmware_minor: Default firmware minor version
32
32
  notes: Human-readable notes about this product
@@ -21,8 +21,8 @@
21
21
  # default_tile_count: <number> # Default number of tiles in chain
22
22
  # min_tile_count: <number> # Minimum tiles supported
23
23
  # max_tile_count: <number> # Maximum tiles supported
24
- # tile_width: <number> # Width of each tile in pixels
25
- # tile_height: <number> # Height of each tile in pixels
24
+ # tile_width: <number> # Width of each tile in zones
25
+ # tile_height: <number> # Height of each tile in zones
26
26
  #
27
27
  # # Host firmware version (optional, overrides automatic firmware selection)
28
28
  # default_firmware_major: <number> # Firmware major version (e.g., 3)
@@ -167,29 +167,29 @@ products:
167
167
  # Matrix Products (Tiles, Candles, etc.)
168
168
  # ========================================
169
169
 
170
- 55: # LIFX Tile, 8x8 pixel matrix, chainable up to 5
170
+ 55: # LIFX Tile, 8x8 zone matrix, chainable up to 5
171
171
  default_tile_count: 5
172
172
  min_tile_count: 1
173
173
  max_tile_count: 5
174
174
  tile_width: 8
175
175
  tile_height: 8
176
- notes: LIFX Tile, 8x8 pixel matrix, chainable up to 5
176
+ notes: LIFX Tile, 8x8 zone matrix, chainable up to 5
177
177
 
178
- 57: # LIFX Candle, 5x6 pixel matrix, single unit
178
+ 57: # LIFX Candle, 5x6 zone matrix, single unit
179
179
  default_tile_count: 1
180
180
  min_tile_count: 1
181
181
  max_tile_count: 1
182
182
  tile_width: 5
183
183
  tile_height: 6
184
- notes: LIFX Candle, 5x6 pixel matrix, single unit
184
+ notes: LIFX Candle, 5x6 zone matrix, single unit
185
185
 
186
- 68: # LIFX Candle variant, 5x6 pixel matrix
186
+ 68: # LIFX Candle variant, 5x6 zone matrix
187
187
  default_tile_count: 1
188
188
  min_tile_count: 1
189
189
  max_tile_count: 1
190
190
  tile_width: 5
191
191
  tile_height: 6
192
- notes: LIFX Candle variant, 5x6 pixel matrix
192
+ notes: LIFX Candle variant, 5x6 zone matrix
193
193
 
194
194
  137: # LIFX Candle Color US 5x6 variant
195
195
  default_tile_count: 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator
3
- Version: 2.2.1
3
+ Version: 2.3.1
4
4
  Summary: LIFX Emulator for testing LIFX LAN protocol libraries
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -1,10 +1,10 @@
1
1
  lifx_emulator/__init__.py,sha256=vjhtpAQRSsUZtaUGCQKbmPALvwZ_BF8Mko8w6jzVqBw,819
2
- lifx_emulator/__main__.py,sha256=zaul9OQhN5csqOqxGWXkVrlurfo2R_-YvM6URk4QAME,21680
2
+ lifx_emulator/__main__.py,sha256=C1Khr8MdrDJnRUM5Y-CV1MPf7CqS2qM-vynGDJjdMYg,21678
3
3
  lifx_emulator/constants.py,sha256=DFZkUsdewE-x_3MgO28tMGkjUCWPeYc3xLj_EXViGOw,1032
4
4
  lifx_emulator/server.py,sha256=r2JYFcpZIqqhue-Nfq7FbN0KfC3XDf3XDb6b43DsiCk,16438
5
5
  lifx_emulator/api/__init__.py,sha256=FoEPw_In5-H_BDQ-XIIONvgj-UqIDVtejIEVRv9qmV8,647
6
6
  lifx_emulator/api/app.py,sha256=IxK8sC7MgdtkoLz8iXcEt02nPDaVgdKJgEiGnzTs-YE,4880
7
- lifx_emulator/api/models.py,sha256=eBx80Ece_4Wv6aqxb1CsZEob9CF0WmR9oJGz3hh14x8,3973
7
+ lifx_emulator/api/models.py,sha256=qFNo0sOl31yuZLtWmLroSW6f6jck-RhP05tx972xsWA,3971
8
8
  lifx_emulator/api/mappers/__init__.py,sha256=ZPCOQR9odcwn0C58AjFW6RvBXe5gOll_QS5lAabgorQ,152
9
9
  lifx_emulator/api/mappers/device_mapper.py,sha256=EGOpdao9ZS-vT4T8IoV-AoN5WucTnqpQO92dYizo3vw,4151
10
10
  lifx_emulator/api/routers/__init__.py,sha256=kbMefnuXrEsYeMA9J4YK_wVs87_XcH7hwkEifR-zgMc,369
@@ -13,33 +13,33 @@ lifx_emulator/api/routers/monitoring.py,sha256=qgVBNm6iMESf1W6EE22DvLalMnxkr0pRb
13
13
  lifx_emulator/api/routers/scenarios.py,sha256=0axSQ9r6rByvXLvqRqOU2ma5nTvZgZ0IIzEXdtzoPnM,9743
14
14
  lifx_emulator/api/services/__init__.py,sha256=ttjjZfAxbDQC_Ep0LkXjopNiVZOFPsFDSOHhBN98v5s,277
15
15
  lifx_emulator/api/services/device_service.py,sha256=r3uFWApC8sVQMCuuzkyjm27K4LDpZnnHmQNgXWX40ok,6294
16
- lifx_emulator/api/templates/dashboard.html,sha256=YXQ9jrs30DZIxtMWFE4E2HqmsgHQ-NeWTTQxQ-7BfHk,33800
16
+ lifx_emulator/api/templates/dashboard.html,sha256=h-PeOH_La5bVOUBcXmTY2leRlMdL8D8yJ-NCx3S16-A,33792
17
17
  lifx_emulator/devices/__init__.py,sha256=QlBTPnFErJcSKLvGyeDwemh7xcpjYvB_L5siKsjr3s8,1089
18
- lifx_emulator/devices/device.py,sha256=LMdg__95n6geG_32j7qp5yl51WNS3ZbCXn-xMfVVikE,13294
18
+ lifx_emulator/devices/device.py,sha256=24rknbLw_EWF8dheED89wvovKjvC2CdfifLvG7g3SiQ,13648
19
19
  lifx_emulator/devices/manager.py,sha256=XDrT82um5sgNpNihLj5RsNvHqdVI1bK9YY2eBzWIcf0,8162
20
20
  lifx_emulator/devices/observers.py,sha256=-KnUgFcKdhlNo7CNVstP-u0wU2W0JAGg055ZPV15Sj0,3874
21
21
  lifx_emulator/devices/persistence.py,sha256=9Mhj46-xrweOmyzjORCi2jKIwa8XJWpQ5CgaKcw6U98,10513
22
22
  lifx_emulator/devices/state_restorer.py,sha256=eDsRSW-2RviP_0Qlk2DHqMaB-zhV0X1cNQECv2lD1qc,9809
23
- lifx_emulator/devices/state_serializer.py,sha256=O4Cp3bbGkd4eZf5jzb0MKzWDTgiNhrSGgypmMWaB4dg,5097
24
- lifx_emulator/devices/states.py,sha256=mVZz7FQeIHLpv2SokmhlQlSBIyVj3GuhGMHBVoFlJqk,10836
23
+ lifx_emulator/devices/state_serializer.py,sha256=aws4LUmXBJS8oBrQziJtlV0XMvCTm5X4dGkGlO_QHcM,6281
24
+ lifx_emulator/devices/states.py,sha256=szWmarFjTBZO1UljEdjvS4W-nanYOzgE3P5df36T5bY,12092
25
25
  lifx_emulator/factories/__init__.py,sha256=yN8i_Hu_cFEryWZmh0TiOQvWEYFVIApQSs4xeb0EfBk,1170
26
- lifx_emulator/factories/builder.py,sha256=OaDqQDGkAyZCSO-4HAsFSd5UzsHpHvRyBk-Fotl1mAY,12056
26
+ lifx_emulator/factories/builder.py,sha256=6b0frEUMnM-RE2yjoVJzKUav3xn9bOElJPOETSG4NWk,12054
27
27
  lifx_emulator/factories/default_config.py,sha256=FTcxKDfeTmO49GTSki8nxnEIZQzR0Lg0hL_PwHUrkVQ,4828
28
- lifx_emulator/factories/factory.py,sha256=VQfU5M8zrpFyNHjpGP1q-3bpek9MltBdoAUSvIvt7Bs,7583
28
+ lifx_emulator/factories/factory.py,sha256=Q2Yr21EC2bLOWwLyqqoUIsJKwWt7MTNODERhTRH6llk,7579
29
29
  lifx_emulator/factories/firmware_config.py,sha256=tPN5Hq-uNb1xzW9Q0A9jD-G0-NaGfINcD0i1XZRUMoE,2711
30
30
  lifx_emulator/factories/serial_generator.py,sha256=MbaXoommsj76ho8_ZoKuUDnffDf98YvwQiXZSWsUsEs,2507
31
31
  lifx_emulator/handlers/__init__.py,sha256=3Hj1hRo3yL3E7GKwG9TaYh33ymk_N3bRiQ8nvqSQULA,1306
32
32
  lifx_emulator/handlers/base.py,sha256=0avCLXY_rNlw16PpJ5JrRCwXNE4uMpBqF3PfSfNJ0b8,1654
33
33
  lifx_emulator/handlers/device_handlers.py,sha256=1AmslA4Ut6L7b3SfduDdvnQizTpzUB3KKWBXmp4WYLQ,9462
34
- lifx_emulator/handlers/light_handlers.py,sha256=Ryz-_fzoVCT6DBkXhW9YCOYJYaMRcBOIguL3HrQXhAw,11471
34
+ lifx_emulator/handlers/light_handlers.py,sha256=255aoiIjSIL63kbHQa6wqUpEwFzFFx7SG6P1nWM9jgU,17769
35
35
  lifx_emulator/handlers/multizone_handlers.py,sha256=2dYsitq0KzEaxEAJmz7ixtir1tvFMOAnfkBQqslqbPM,7914
36
36
  lifx_emulator/handlers/registry.py,sha256=s1ht4PmPhXhAcwu1hoY4yW39wy3SPJBMY-9Uxd0FWuE,3292
37
- lifx_emulator/handlers/tile_handlers.py,sha256=Ci_SrOldf2I8djEd2JJZZ5iR5b4_HvzlWTkaBiCTx7c,12785
37
+ lifx_emulator/handlers/tile_handlers.py,sha256=L4fNKGTSSIxRuqKrfDrMSrNPvDJr3aIuaEqbhRCOt04,17176
38
38
  lifx_emulator/products/__init__.py,sha256=qcNop_kRYFF3zSjNemzQEgu3jPrIxfyQyLv9GsnaLEI,627
39
- lifx_emulator/products/generator.py,sha256=5zcq0iKgwjtg0gePnBEOdIkumesvfzEcKRdBZFPyGtk,33538
39
+ lifx_emulator/products/generator.py,sha256=WsbAr2dXXMtLyOlFFkt-xM9kT5WpiUzj6_FKRf16Tng,33536
40
40
  lifx_emulator/products/registry.py,sha256=qkm2xgGZo_ds3wAbYplLu4gb0cxhjZXjnCc1V8etpHw,46517
41
- lifx_emulator/products/specs.py,sha256=RxToCvu-iwF6QMcmYgI5CblLOr8hpDZ3k3WT1fZcKe8,8730
42
- lifx_emulator/products/specs.yml,sha256=eM_r1lTRQHii4dyvnsZWx1P7NzDSZzJl03aR86TY564,9683
41
+ lifx_emulator/products/specs.py,sha256=epqz2DPyNOOOFHhmI_wlk7iEbgN0vCugHz-hWx9FlAI,8728
42
+ lifx_emulator/products/specs.yml,sha256=-91JNzGhwcO_zybOWY8dFBncN2TnnxtSkkHdi31KT94,9675
43
43
  lifx_emulator/protocol/__init__.py,sha256=-wjC-wBcb7fxi5I-mJr2Ad8K2YRflJFdLLdobfD-W1Q,56
44
44
  lifx_emulator/protocol/base.py,sha256=V6t0baSgIXjrsz2dBuUn_V9xwradSqMxBFJHAUtnfCs,15368
45
45
  lifx_emulator/protocol/const.py,sha256=ilhv-KcQpHtKh2MDCaIbMLQAsxKO_uTaxyR63v1W8cc,226
@@ -55,8 +55,8 @@ lifx_emulator/scenarios/__init__.py,sha256=CGjudoWvyysvFj2xej11N2cr3mYROGtRb9zVH
55
55
  lifx_emulator/scenarios/manager.py,sha256=1esxRdz74UynNk1wb86MGZ2ZFAuMzByuu74nRe3D-Og,11163
56
56
  lifx_emulator/scenarios/models.py,sha256=BKS_fGvrbkGe-vK3arZ0w2f9adS1UZhiOoKpu7GENnc,4099
57
57
  lifx_emulator/scenarios/persistence.py,sha256=3vjtPNFYfag38tUxuqxkGpWhQ7uBitc1rLroSAuw9N8,8881
58
- lifx_emulator-2.2.1.dist-info/METADATA,sha256=AWoxTE55eL8Ga1ORQaXG0gammepJigzKqTrxDj-1UqA,4549
59
- lifx_emulator-2.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
60
- lifx_emulator-2.2.1.dist-info/entry_points.txt,sha256=R9C_K_tTgt6yXEmhzH4r2Yx2Tu1rLlnYzeG4RFUVzSc,62
61
- lifx_emulator-2.2.1.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
62
- lifx_emulator-2.2.1.dist-info/RECORD,,
58
+ lifx_emulator-2.3.1.dist-info/METADATA,sha256=L3tETYNKBl5pR1wfSXTsWijV0olIPspASI1du2HMO94,4549
59
+ lifx_emulator-2.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
60
+ lifx_emulator-2.3.1.dist-info/entry_points.txt,sha256=R9C_K_tTgt6yXEmhzH4r2Yx2Tu1rLlnYzeG4RFUVzSc,62
61
+ lifx_emulator-2.3.1.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
62
+ lifx_emulator-2.3.1.dist-info/RECORD,,