lifx-emulator 2.2.0__py3-none-any.whl → 2.3.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.
@@ -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 (
@@ -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
+ pixels = width * height
104
+ self.framebuffers[fb_index] = [
105
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
106
+ for _ in range(pixels)
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:
@@ -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,6 +138,10 @@ class Get64Handler(PacketHandler):
137
138
  tile_width = tile["width"]
138
139
  tile_height = tile["height"]
139
140
 
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
+
140
145
  # Calculate how many rows fit in 64 pixels
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)
@@ -161,8 +166,8 @@ class Get64Handler(PacketHandler):
161
166
 
162
167
  # Calculate pixel index in flat color array
163
168
  pixel_idx = y * tile_width + x
164
- if pixel_idx < len(tile["colors"]):
165
- colors.append(tile["colors"][pixel_idx])
169
+ if pixel_idx < len(tile_colors):
170
+ colors.append(tile_colors[pixel_idx])
166
171
  else:
167
172
  colors.append(
168
173
  LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
@@ -173,7 +178,14 @@ class Get64Handler(PacketHandler):
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,60 @@ 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 pixels
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
+ pixels_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 pixels_written >= 64:
242
+ pixels_written += 1
243
+ continue
244
+
245
+ # Calculate pixel index in flat color array
246
+ pixel_idx = y * tile_width + x
247
+ if pixel_idx < len(target_colors) and pixels_written < len(
248
+ packet.colors
249
+ ):
250
+ target_colors[pixel_idx] = packet.colors[pixels_written]
251
+ pixels_written += 1
252
+
253
+ logger.info(
254
+ f"Tile {tile_index} FB{fb_index} set {pixels_written} colors at "
255
+ f"({rect.x},{rect.y}), duration={packet.duration}ms"
256
+ )
201
257
 
202
258
  # Tiles never return a response to Set64 regardless of res_required
203
259
  # https://lan.developer.lifx.com/docs/changing-a-device#set64---packet-715
@@ -212,12 +268,83 @@ class CopyFrameBufferHandler(PacketHandler):
212
268
  def handle(
213
269
  self, device_state: DeviceState, packet: Any | None, res_required: bool
214
270
  ) -> list[Any]:
215
- if not device_state.has_matrix:
271
+ if not device_state.has_matrix or not packet:
272
+ return []
273
+
274
+ tile_index = packet.tile_index
275
+ if tile_index >= len(device_state.tile_devices):
216
276
  return []
217
277
 
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
278
+ tile = device_state.tile_devices[tile_index]
279
+ tile_width = tile["width"]
280
+ tile_height = tile["height"]
281
+
282
+ src_fb_index = packet.src_fb_index
283
+ dst_fb_index = packet.dst_fb_index
284
+
285
+ # Get source framebuffer
286
+ if src_fb_index == 0:
287
+ src_colors = tile["colors"]
288
+ else:
289
+ if tile_index < len(device_state.tile_framebuffers):
290
+ fb_storage = device_state.tile_framebuffers[tile_index]
291
+ src_colors = fb_storage.get_framebuffer(
292
+ src_fb_index, tile_width, tile_height
293
+ )
294
+ else:
295
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
296
+ return []
297
+
298
+ # Get destination framebuffer
299
+ if dst_fb_index == 0:
300
+ dst_colors = tile["colors"]
301
+ else:
302
+ if tile_index < len(device_state.tile_framebuffers):
303
+ fb_storage = device_state.tile_framebuffers[tile_index]
304
+ dst_colors = fb_storage.get_framebuffer(
305
+ dst_fb_index, tile_width, tile_height
306
+ )
307
+ else:
308
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
309
+ return []
310
+
311
+ # Copy the specified rectangle from source to destination
312
+ src_x = packet.src_x
313
+ src_y = packet.src_y
314
+ dst_x = packet.dst_x
315
+ dst_y = packet.dst_y
316
+ width = packet.width
317
+ height = packet.height
318
+
319
+ pixels_copied = 0
320
+ for row in range(height):
321
+ src_row = src_y + row
322
+ dst_row = dst_y + row
323
+
324
+ if src_row >= tile_height or dst_row >= tile_height:
325
+ break
326
+
327
+ for col in range(width):
328
+ src_col = src_x + col
329
+ dst_col = dst_x + col
330
+
331
+ if src_col >= tile_width or dst_col >= tile_width:
332
+ continue
333
+
334
+ src_idx = src_row * tile_width + src_col
335
+ dst_idx = dst_row * tile_width + dst_col
336
+
337
+ if src_idx < len(src_colors) and dst_idx < len(dst_colors):
338
+ dst_colors[dst_idx] = src_colors[src_idx]
339
+ pixels_copied += 1
340
+
341
+ logger.info(
342
+ f"Tile {tile_index} copied {pixels_copied} pixels from "
343
+ f"FB{src_fb_index}({src_x},{src_y}) to "
344
+ f"FB{dst_fb_index}({dst_x},{dst_y}), "
345
+ f"size={width}x{height}, duration={packet.duration}ms"
346
+ )
347
+
221
348
  return []
222
349
 
223
350
 
@@ -240,12 +367,25 @@ class GetEffectHandler(PacketHandler):
240
367
  # Create effect settings with Sky parameters
241
368
  from lifx_emulator.protocol.protocol_types import TileEffectSkyType
242
369
 
243
- # Use defaults for SKY effect (type=5), otherwise use stored values
370
+ # Use defaults for SKY effect when values are None, otherwise use stored values
371
+ # NOTE: Must check for None explicitly, not use 'or', because SUNRISE=0 is falsy
244
372
  effect_type = TileEffectType(device_state.tile_effect_type)
245
373
  if effect_type == TileEffectType.SKY:
246
- sky_type = device_state.tile_effect_sky_type or TileEffectSkyType.CLOUDS
247
- cloud_sat_min = device_state.tile_effect_cloud_sat_min or 50
248
- cloud_sat_max = device_state.tile_effect_cloud_sat_max or 180
374
+ sky_type = (
375
+ device_state.tile_effect_sky_type
376
+ if device_state.tile_effect_sky_type is not None
377
+ else TileEffectSkyType.CLOUDS
378
+ )
379
+ cloud_sat_min = (
380
+ device_state.tile_effect_cloud_sat_min
381
+ if device_state.tile_effect_cloud_sat_min is not None
382
+ else 50
383
+ )
384
+ cloud_sat_max = (
385
+ device_state.tile_effect_cloud_sat_max
386
+ if device_state.tile_effect_cloud_sat_max is not None
387
+ else 180
388
+ )
249
389
  else:
250
390
  sky_type = device_state.tile_effect_sky_type
251
391
  cloud_sat_min = device_state.tile_effect_cloud_sat_min
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator
3
- Version: 2.2.0
3
+ Version: 2.3.0
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>
@@ -15,13 +15,13 @@ lifx_emulator/api/services/__init__.py,sha256=ttjjZfAxbDQC_Ep0LkXjopNiVZOFPsFDSO
15
15
  lifx_emulator/api/services/device_service.py,sha256=r3uFWApC8sVQMCuuzkyjm27K4LDpZnnHmQNgXWX40ok,6294
16
16
  lifx_emulator/api/templates/dashboard.html,sha256=YXQ9jrs30DZIxtMWFE4E2HqmsgHQ-NeWTTQxQ-7BfHk,33800
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=LIVXURglYsYMC6_88sAWzoJKkq_HSZEOu4xruRtcZKs,13650
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=O__VtgK97-ZHxZ2qgOKp9-fDG8HcmlTVkGYONwot8iQ,12094
25
25
  lifx_emulator/factories/__init__.py,sha256=yN8i_Hu_cFEryWZmh0TiOQvWEYFVIApQSs4xeb0EfBk,1170
26
26
  lifx_emulator/factories/builder.py,sha256=OaDqQDGkAyZCSO-4HAsFSd5UzsHpHvRyBk-Fotl1mAY,12056
27
27
  lifx_emulator/factories/default_config.py,sha256=FTcxKDfeTmO49GTSki8nxnEIZQzR0Lg0hL_PwHUrkVQ,4828
@@ -34,7 +34,7 @@ lifx_emulator/handlers/device_handlers.py,sha256=1AmslA4Ut6L7b3SfduDdvnQizTpzUB3
34
34
  lifx_emulator/handlers/light_handlers.py,sha256=Ryz-_fzoVCT6DBkXhW9YCOYJYaMRcBOIguL3HrQXhAw,11471
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=tQSq5hptq_CWlsc0q_I1D3WLcNmOliR47gY2AZI9AEs,12329
37
+ lifx_emulator/handlers/tile_handlers.py,sha256=tniYndUbtWPTu7YznfMKsWUBu2UmGsSuOYRws5IBL0s,17239
38
38
  lifx_emulator/products/__init__.py,sha256=qcNop_kRYFF3zSjNemzQEgu3jPrIxfyQyLv9GsnaLEI,627
39
39
  lifx_emulator/products/generator.py,sha256=5zcq0iKgwjtg0gePnBEOdIkumesvfzEcKRdBZFPyGtk,33538
40
40
  lifx_emulator/products/registry.py,sha256=qkm2xgGZo_ds3wAbYplLu4gb0cxhjZXjnCc1V8etpHw,46517
@@ -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.0.dist-info/METADATA,sha256=IYeBVFaQco74YbRC2s-orGbU4zWnrTrNAnwiT08nxPM,4549
59
- lifx_emulator-2.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
60
- lifx_emulator-2.2.0.dist-info/entry_points.txt,sha256=R9C_K_tTgt6yXEmhzH4r2Yx2Tu1rLlnYzeG4RFUVzSc,62
61
- lifx_emulator-2.2.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
62
- lifx_emulator-2.2.0.dist-info/RECORD,,
58
+ lifx_emulator-2.3.0.dist-info/METADATA,sha256=PsbeOthCdD824wzYnw8vf_rKKze3Yk1DPC6NtpkZv1Q,4549
59
+ lifx_emulator-2.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
60
+ lifx_emulator-2.3.0.dist-info/entry_points.txt,sha256=R9C_K_tTgt6yXEmhzH4r2Yx2Tu1rLlnYzeG4RFUVzSc,62
61
+ lifx_emulator-2.3.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
62
+ lifx_emulator-2.3.0.dist-info/RECORD,,