lifx-emulator 1.0.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.
Files changed (40) hide show
  1. lifx_emulator/__init__.py +31 -0
  2. lifx_emulator/__main__.py +607 -0
  3. lifx_emulator/api.py +1825 -0
  4. lifx_emulator/async_storage.py +308 -0
  5. lifx_emulator/constants.py +33 -0
  6. lifx_emulator/device.py +750 -0
  7. lifx_emulator/device_states.py +114 -0
  8. lifx_emulator/factories.py +380 -0
  9. lifx_emulator/handlers/__init__.py +39 -0
  10. lifx_emulator/handlers/base.py +49 -0
  11. lifx_emulator/handlers/device_handlers.py +340 -0
  12. lifx_emulator/handlers/light_handlers.py +372 -0
  13. lifx_emulator/handlers/multizone_handlers.py +249 -0
  14. lifx_emulator/handlers/registry.py +110 -0
  15. lifx_emulator/handlers/tile_handlers.py +309 -0
  16. lifx_emulator/observers.py +139 -0
  17. lifx_emulator/products/__init__.py +28 -0
  18. lifx_emulator/products/generator.py +771 -0
  19. lifx_emulator/products/registry.py +1446 -0
  20. lifx_emulator/products/specs.py +242 -0
  21. lifx_emulator/products/specs.yml +327 -0
  22. lifx_emulator/protocol/__init__.py +1 -0
  23. lifx_emulator/protocol/base.py +334 -0
  24. lifx_emulator/protocol/const.py +8 -0
  25. lifx_emulator/protocol/generator.py +1371 -0
  26. lifx_emulator/protocol/header.py +159 -0
  27. lifx_emulator/protocol/packets.py +1351 -0
  28. lifx_emulator/protocol/protocol_types.py +844 -0
  29. lifx_emulator/protocol/serializer.py +379 -0
  30. lifx_emulator/scenario_manager.py +402 -0
  31. lifx_emulator/scenario_persistence.py +206 -0
  32. lifx_emulator/server.py +482 -0
  33. lifx_emulator/state_restorer.py +259 -0
  34. lifx_emulator/state_serializer.py +130 -0
  35. lifx_emulator/storage_protocol.py +100 -0
  36. lifx_emulator-1.0.0.dist-info/METADATA +445 -0
  37. lifx_emulator-1.0.0.dist-info/RECORD +40 -0
  38. lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
  39. lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
  40. lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
@@ -0,0 +1,249 @@
1
+ """MultiZone packet handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from lifx_emulator.handlers.base import PacketHandler
9
+ from lifx_emulator.protocol.packets import MultiZone
10
+ from lifx_emulator.protocol.protocol_types import (
11
+ LightHsbk,
12
+ MultiZoneEffectParameter,
13
+ MultiZoneEffectSettings,
14
+ MultiZoneEffectType,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from lifx_emulator.device import DeviceState
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class GetColorZonesHandler(PacketHandler):
24
+ """Handle MultiZoneGetColorZones (502) -> StateMultiZone (506) packets."""
25
+
26
+ PKT_TYPE = MultiZone.GetColorZones.PKT_TYPE
27
+
28
+ def handle(
29
+ self,
30
+ device_state: DeviceState,
31
+ packet: MultiZone.GetColorZones | None,
32
+ res_required: bool,
33
+ ) -> list[Any]:
34
+ if not device_state.has_multizone:
35
+ return []
36
+
37
+ start_index = packet.start_index if packet else 0
38
+ end_index = packet.end_index if packet else 0
39
+
40
+ # Return multiple StateMultiZone packets, each containing up to 8 zones
41
+ responses = []
42
+
43
+ # Send packets of up to 8 zones each (StateMultiZone format)
44
+ index = start_index
45
+ while index <= end_index and index < device_state.zone_count:
46
+ # Collect up to 8 zones for this packet
47
+ colors = []
48
+ for i in range(8):
49
+ zone_index = index + i
50
+ if zone_index < device_state.zone_count and zone_index <= end_index:
51
+ zone_color = (
52
+ device_state.zone_colors[zone_index]
53
+ if zone_index < len(device_state.zone_colors)
54
+ else LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
55
+ )
56
+ colors.append(zone_color)
57
+ else:
58
+ # Pad remaining slots with black
59
+ colors.append(
60
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
61
+ )
62
+
63
+ # Pad to exactly 8 colors
64
+ while len(colors) < 8:
65
+ colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
66
+
67
+ packet_obj = MultiZone.StateMultiZone(
68
+ count=device_state.zone_count, index=index, colors=colors
69
+ )
70
+ responses.append(packet_obj)
71
+
72
+ index += 8
73
+
74
+ return responses
75
+
76
+
77
+ class SetColorZonesHandler(PacketHandler):
78
+ """Handle MultiZoneSetColorZones (501)."""
79
+
80
+ PKT_TYPE = MultiZone.SetColorZones.PKT_TYPE
81
+
82
+ def handle(
83
+ self,
84
+ device_state: DeviceState,
85
+ packet: MultiZone.SetColorZones | None,
86
+ res_required: bool,
87
+ ) -> list[Any]:
88
+ if not device_state.has_multizone:
89
+ return []
90
+
91
+ if packet:
92
+ start_index = packet.start_index
93
+ end_index = packet.end_index
94
+
95
+ # Update zone colors
96
+ for i in range(start_index, min(end_index + 1, device_state.zone_count)):
97
+ if i < len(device_state.zone_colors):
98
+ device_state.zone_colors[i] = packet.color
99
+
100
+ logger.info(
101
+ f"MultiZone set zones {start_index}-{end_index} to color, "
102
+ f"duration={packet.duration}ms"
103
+ )
104
+
105
+ if res_required and packet:
106
+ # Create a GetColorZones packet to reuse the get handler
107
+ get_packet = MultiZone.GetColorZones(
108
+ start_index=packet.start_index, end_index=packet.end_index
109
+ )
110
+ # Reuse GetColorZonesHandler
111
+ handler = GetColorZonesHandler()
112
+ return handler.handle(device_state, get_packet, res_required)
113
+ return []
114
+
115
+
116
+ class ExtendedGetColorZonesHandler(PacketHandler):
117
+ """Handle MultiZoneExtendedGetColorZones (511) -> ExtendedStateMultiZone (512)."""
118
+
119
+ PKT_TYPE = MultiZone.ExtendedGetColorZones.PKT_TYPE
120
+
121
+ def handle(
122
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
123
+ ) -> list[Any]:
124
+ if not device_state.has_multizone:
125
+ return []
126
+
127
+ colors_count = min(82, len(device_state.zone_colors))
128
+ colors = []
129
+ for i in range(colors_count):
130
+ colors.append(device_state.zone_colors[i])
131
+ # Pad to 82 colors
132
+ while len(colors) < 82:
133
+ colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
134
+
135
+ return [
136
+ MultiZone.ExtendedStateMultiZone(
137
+ count=device_state.zone_count,
138
+ index=0,
139
+ colors_count=colors_count,
140
+ colors=colors,
141
+ )
142
+ ]
143
+
144
+
145
+ class ExtendedSetColorZonesHandler(PacketHandler):
146
+ """Handle MultiZoneExtendedSetColorZones (510)."""
147
+
148
+ PKT_TYPE = MultiZone.ExtendedSetColorZones.PKT_TYPE
149
+
150
+ def handle(
151
+ self,
152
+ device_state: DeviceState,
153
+ packet: MultiZone.ExtendedSetColorZones | None,
154
+ res_required: bool,
155
+ ) -> list[Any]:
156
+ if not device_state.has_multizone:
157
+ return []
158
+
159
+ if packet:
160
+ # Update zone colors from packet
161
+ for i, color in enumerate(packet.colors[: packet.colors_count]):
162
+ zone_index = packet.index + i
163
+ if zone_index < len(device_state.zone_colors):
164
+ device_state.zone_colors[zone_index] = color
165
+
166
+ logger.info(
167
+ f"MultiZone extended set {packet.colors_count} zones "
168
+ f"from index {packet.index}, duration={packet.duration}ms"
169
+ )
170
+
171
+ if res_required:
172
+ handler = ExtendedGetColorZonesHandler()
173
+ return handler.handle(device_state, None, res_required)
174
+ return []
175
+
176
+
177
+ class GetEffectHandler(PacketHandler):
178
+ """Handle MultiZoneGetEffect (507) -> StateEffect (509)."""
179
+
180
+ PKT_TYPE = MultiZone.GetEffect.PKT_TYPE
181
+
182
+ def handle(
183
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
184
+ ) -> list[Any]:
185
+ if not device_state.has_multizone:
186
+ return []
187
+
188
+ # Create effect settings
189
+ parameter = MultiZoneEffectParameter(
190
+ parameter0=0,
191
+ parameter1=0,
192
+ parameter2=0,
193
+ parameter3=0,
194
+ parameter4=0,
195
+ parameter5=0,
196
+ parameter6=0,
197
+ parameter7=0,
198
+ )
199
+ settings = MultiZoneEffectSettings(
200
+ instanceid=0,
201
+ type=MultiZoneEffectType(device_state.multizone_effect_type),
202
+ speed=device_state.multizone_effect_speed * 1000, # convert to milliseconds
203
+ duration=0, # infinite
204
+ parameter=parameter,
205
+ )
206
+
207
+ return [MultiZone.StateEffect(settings=settings)]
208
+
209
+
210
+ class SetEffectHandler(PacketHandler):
211
+ """Handle MultiZoneSetEffect (508) -> StateEffect (509)."""
212
+
213
+ PKT_TYPE = MultiZone.SetEffect.PKT_TYPE
214
+
215
+ def handle(
216
+ self,
217
+ device_state: DeviceState,
218
+ packet: MultiZone.SetEffect | None,
219
+ res_required: bool,
220
+ ) -> list[Any]:
221
+ if not device_state.has_multizone:
222
+ return []
223
+
224
+ if packet:
225
+ device_state.multizone_effect_type = int(packet.settings.type)
226
+ device_state.multizone_effect_speed = (
227
+ packet.settings.speed // 1000
228
+ ) # convert to seconds
229
+
230
+ logger.info(
231
+ f"MultiZone effect set: type={packet.settings.type}, "
232
+ f"speed={packet.settings.speed}ms"
233
+ )
234
+
235
+ if res_required:
236
+ handler = GetEffectHandler()
237
+ return handler.handle(device_state, None, res_required)
238
+ return []
239
+
240
+
241
+ # List of all multizone handlers for easy registration
242
+ ALL_MULTIZONE_HANDLERS = [
243
+ GetColorZonesHandler(),
244
+ SetColorZonesHandler(),
245
+ ExtendedGetColorZonesHandler(),
246
+ ExtendedSetColorZonesHandler(),
247
+ GetEffectHandler(),
248
+ SetEffectHandler(),
249
+ ]
@@ -0,0 +1,110 @@
1
+ """Handler registry for managing packet handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from lifx_emulator.handlers.base import PacketHandler
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HandlerRegistry:
15
+ """Registry for packet handlers using Strategy pattern.
16
+
17
+ The registry maps packet type numbers to handler instances.
18
+ Handlers can be registered individually or in bulk.
19
+
20
+ Example:
21
+ >>> registry = HandlerRegistry()
22
+ >>> registry.register(GetServiceHandler())
23
+ >>> handler = registry.get_handler(2) # Device.GetService
24
+ >>> response = handler.handle(device_state, None, True)
25
+ """
26
+
27
+ def __init__(self):
28
+ """Initialize an empty handler registry."""
29
+ self._handlers: dict[int, PacketHandler] = {}
30
+
31
+ def register(self, handler: PacketHandler) -> None:
32
+ """Register a packet handler.
33
+
34
+ Args:
35
+ handler: Handler instance to register
36
+
37
+ Raises:
38
+ ValueError: If handler doesn't have PKT_TYPE attribute
39
+
40
+ Note:
41
+ If a handler for this packet type already exists, it will be replaced.
42
+ """
43
+ if not hasattr(handler, "PKT_TYPE"):
44
+ raise ValueError(
45
+ f"Handler {handler.__class__.__name__} missing PKT_TYPE attribute"
46
+ )
47
+
48
+ pkt_type = handler.PKT_TYPE
49
+
50
+ # Warn if replacing existing handler
51
+ if pkt_type in self._handlers:
52
+ old_handler = self._handlers[pkt_type]
53
+ logger.warning(
54
+ f"Replacing handler for packet type {pkt_type}: "
55
+ f"{old_handler.__class__.__name__} -> {handler.__class__.__name__}"
56
+ )
57
+
58
+ self._handlers[pkt_type] = handler
59
+ logger.debug(
60
+ f"Registered {handler.__class__.__name__} for packet type {pkt_type}"
61
+ )
62
+
63
+ def register_all(self, handlers: list[PacketHandler]) -> None:
64
+ """Register multiple handlers at once.
65
+
66
+ Args:
67
+ handlers: List of handler instances to register
68
+ """
69
+ for handler in handlers:
70
+ self.register(handler)
71
+
72
+ def get_handler(self, pkt_type: int) -> PacketHandler | None:
73
+ """Get handler for a packet type.
74
+
75
+ Args:
76
+ pkt_type: Packet type number
77
+
78
+ Returns:
79
+ Handler instance if registered, None otherwise
80
+ """
81
+ return self._handlers.get(pkt_type)
82
+
83
+ def has_handler(self, pkt_type: int) -> bool:
84
+ """Check if a handler is registered for a packet type.
85
+
86
+ Args:
87
+ pkt_type: Packet type number
88
+
89
+ Returns:
90
+ True if handler is registered, False otherwise
91
+ """
92
+ return pkt_type in self._handlers
93
+
94
+ def list_handlers(self) -> list[tuple[int, str]]:
95
+ """List all registered handlers.
96
+
97
+ Returns:
98
+ List of (packet_type, handler_class_name) tuples
99
+ """
100
+ return [
101
+ (pkt_type, handler.__class__.__name__)
102
+ for pkt_type, handler in sorted(self._handlers.items())
103
+ ]
104
+
105
+ def __len__(self) -> int:
106
+ """Return number of registered handlers."""
107
+ return len(self._handlers)
108
+
109
+ def __repr__(self) -> str:
110
+ return f"HandlerRegistry({len(self)} handlers)"
@@ -0,0 +1,309 @@
1
+ """Tile packet handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from lifx_emulator.handlers.base import PacketHandler
9
+ from lifx_emulator.protocol.packets import Tile
10
+ from lifx_emulator.protocol.protocol_types import (
11
+ DeviceStateHostFirmware,
12
+ DeviceStateVersion,
13
+ LightHsbk,
14
+ TileAccelMeas,
15
+ TileEffectParameter,
16
+ TileEffectSettings,
17
+ TileEffectType,
18
+ TileStateDevice,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from lifx_emulator.device import DeviceState
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class GetDeviceChainHandler(PacketHandler):
28
+ """Handle TileGetDeviceChain (701) -> StateDeviceChain (702)."""
29
+
30
+ PKT_TYPE = Tile.GetDeviceChain.PKT_TYPE
31
+
32
+ def handle(
33
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
34
+ ) -> list[Any]:
35
+ if not device_state.has_matrix:
36
+ return []
37
+
38
+ # Build tile device list (max 16 tiles in protocol)
39
+ tile_devices = []
40
+ for tile in device_state.tile_devices[:16]:
41
+ accel_meas = TileAccelMeas(
42
+ x=tile["accel_meas_x"], y=tile["accel_meas_y"], z=tile["accel_meas_z"]
43
+ )
44
+ device_version = DeviceStateVersion(
45
+ vendor=tile["device_version_vendor"],
46
+ product=tile["device_version_product"],
47
+ )
48
+ firmware = DeviceStateHostFirmware(
49
+ build=tile["firmware_build"],
50
+ version_minor=tile["firmware_version_minor"],
51
+ version_major=tile["firmware_version_major"],
52
+ )
53
+ tile_device = TileStateDevice(
54
+ accel_meas=accel_meas,
55
+ user_x=tile["user_x"],
56
+ user_y=tile["user_y"],
57
+ width=tile["width"],
58
+ height=tile["height"],
59
+ device_version=device_version,
60
+ firmware=firmware,
61
+ )
62
+ tile_devices.append(tile_device)
63
+
64
+ # Pad to 16 tiles
65
+ while len(tile_devices) < 16:
66
+ dummy_accel = TileAccelMeas(x=0, y=0, z=0)
67
+ dummy_version = DeviceStateVersion(vendor=0, product=0)
68
+ dummy_firmware = DeviceStateHostFirmware(
69
+ build=0, version_minor=0, version_major=0
70
+ )
71
+ dummy_tile = TileStateDevice(
72
+ accel_meas=dummy_accel,
73
+ user_x=0.0,
74
+ user_y=0.0,
75
+ width=0,
76
+ height=0,
77
+ device_version=dummy_version,
78
+ firmware=dummy_firmware,
79
+ )
80
+ tile_devices.append(dummy_tile)
81
+
82
+ return [
83
+ Tile.StateDeviceChain(
84
+ start_index=0,
85
+ tile_devices=tile_devices,
86
+ tile_devices_count=len(device_state.tile_devices),
87
+ )
88
+ ]
89
+
90
+
91
+ class SetUserPositionHandler(PacketHandler):
92
+ """Handle TileSetUserPosition (703) - update tile position metadata."""
93
+
94
+ PKT_TYPE = Tile.SetUserPosition.PKT_TYPE
95
+
96
+ def handle(
97
+ self,
98
+ device_state: DeviceState,
99
+ packet: Tile.SetUserPosition | None,
100
+ res_required: bool,
101
+ ) -> list[Any]:
102
+ if not device_state.has_matrix or not packet:
103
+ return []
104
+
105
+ logger.info(
106
+ f"Tile user position set: tile_index={packet.tile_index}, "
107
+ f"user_x={packet.user_x}, user_y={packet.user_y}"
108
+ )
109
+
110
+ # Update tile position if we have that tile
111
+ if packet.tile_index < len(device_state.tile_devices):
112
+ device_state.tile_devices[packet.tile_index]["user_x"] = packet.user_x
113
+ device_state.tile_devices[packet.tile_index]["user_y"] = packet.user_y
114
+
115
+ # No response packet defined for this in protocol
116
+ return []
117
+
118
+
119
+ class Get64Handler(PacketHandler):
120
+ """Handle TileGet64 (707) -> State64 (711)."""
121
+
122
+ PKT_TYPE = Tile.Get64.PKT_TYPE
123
+
124
+ def handle(
125
+ self, device_state: DeviceState, packet: Tile.Get64 | None, res_required: bool
126
+ ) -> list[Any]:
127
+ if not device_state.has_matrix or not packet:
128
+ return []
129
+
130
+ tile_index = packet.tile_index
131
+ rect = packet.rect
132
+
133
+ if tile_index >= len(device_state.tile_devices):
134
+ return []
135
+
136
+ tile = device_state.tile_devices[tile_index]
137
+ tile_width = tile["width"]
138
+ tile_height = tile["height"]
139
+
140
+ # Calculate how many rows fit in 64 pixels
141
+ rows_to_return = 64 // rect.width if rect.width > 0 else 1
142
+ rows_to_return = min(rows_to_return, tile_height - rect.y)
143
+
144
+ # Extract colors from the requested rectangle
145
+ colors = []
146
+ pixels_extracted = 0
147
+
148
+ for row in range(rows_to_return):
149
+ y = rect.y + row
150
+ if y >= tile_height:
151
+ break
152
+
153
+ for col in range(rect.width):
154
+ x = rect.x + col
155
+ if x >= tile_width or pixels_extracted >= 64:
156
+ colors.append(
157
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
158
+ )
159
+ pixels_extracted += 1
160
+ continue
161
+
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])
166
+ else:
167
+ colors.append(
168
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
169
+ )
170
+ pixels_extracted += 1
171
+
172
+ # Pad to exactly 64 colors
173
+ while len(colors) < 64:
174
+ colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
175
+
176
+ return [Tile.State64(tile_index=tile_index, rect=rect, colors=colors)]
177
+
178
+
179
+ class Set64Handler(PacketHandler):
180
+ """Handle TileSet64 (715)."""
181
+
182
+ PKT_TYPE = Tile.Set64.PKT_TYPE
183
+
184
+ def handle(
185
+ self, device_state: DeviceState, packet: Tile.Set64 | None, res_required: bool
186
+ ) -> list[Any]:
187
+ if not device_state.has_matrix or not packet:
188
+ return []
189
+
190
+ tile_index = packet.tile_index
191
+
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
197
+
198
+ logger.info(
199
+ f"Tile {tile_index} set 64 colors, duration={packet.duration}ms"
200
+ )
201
+
202
+ # Tiles never return a response to Set64 regardless of res_required
203
+ # https://lan.developer.lifx.com/docs/changing-a-device#set64---packet-715
204
+ return []
205
+
206
+
207
+ class CopyFrameBufferHandler(PacketHandler):
208
+ """Handle TileCopyFrameBuffer (716) - copy frame buffer (no-op in emulator)."""
209
+
210
+ PKT_TYPE = Tile.CopyFrameBuffer.PKT_TYPE
211
+
212
+ def handle(
213
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
214
+ ) -> list[Any]:
215
+ if not device_state.has_matrix:
216
+ return []
217
+
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
221
+ return []
222
+
223
+
224
+ class GetEffectHandler(PacketHandler):
225
+ """Handle TileGetEffect (718) -> StateTileEffect (720)."""
226
+
227
+ PKT_TYPE = Tile.GetEffect.PKT_TYPE
228
+
229
+ def handle(
230
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
231
+ ) -> list[Any]:
232
+ if not device_state.has_matrix:
233
+ return []
234
+
235
+ # Build palette (up to 16 colors)
236
+ palette = list(device_state.tile_effect_palette[:16])
237
+ while len(palette) < 16:
238
+ palette.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
239
+
240
+ # Create effect settings
241
+ parameter = TileEffectParameter(
242
+ parameter0=0,
243
+ parameter1=0,
244
+ parameter2=0,
245
+ parameter3=0,
246
+ parameter4=0,
247
+ parameter5=0,
248
+ parameter6=0,
249
+ parameter7=0,
250
+ )
251
+ settings = TileEffectSettings(
252
+ instanceid=0,
253
+ type=TileEffectType(device_state.tile_effect_type),
254
+ speed=device_state.tile_effect_speed * 1000, # convert to milliseconds
255
+ duration=0, # infinite
256
+ parameter=parameter,
257
+ palette_count=min(len(device_state.tile_effect_palette), 16),
258
+ palette=palette,
259
+ )
260
+
261
+ return [Tile.StateEffect(settings=settings)]
262
+
263
+
264
+ class SetEffectHandler(PacketHandler):
265
+ """Handle TileSetEffect (719) -> StateTileEffect (720)."""
266
+
267
+ PKT_TYPE = Tile.SetEffect.PKT_TYPE
268
+
269
+ def handle(
270
+ self,
271
+ device_state: DeviceState,
272
+ packet: Tile.SetEffect | None,
273
+ res_required: bool,
274
+ ) -> list[Any]:
275
+ if not device_state.has_matrix:
276
+ return []
277
+
278
+ if packet:
279
+ device_state.tile_effect_type = int(packet.settings.type)
280
+ device_state.tile_effect_speed = (
281
+ packet.settings.speed // 1000
282
+ ) # convert to seconds
283
+ device_state.tile_effect_palette = list(
284
+ packet.settings.palette[: packet.settings.palette_count]
285
+ )
286
+ device_state.tile_effect_palette_count = packet.settings.palette_count
287
+
288
+ logger.info(
289
+ f"Tile effect set: type={packet.settings.type}, "
290
+ f"speed={packet.settings.speed}ms, "
291
+ f"palette_count={packet.settings.palette_count}"
292
+ )
293
+
294
+ if res_required:
295
+ handler = GetEffectHandler()
296
+ return handler.handle(device_state, None, res_required)
297
+ return []
298
+
299
+
300
+ # List of all tile handlers for easy registration
301
+ ALL_TILE_HANDLERS = [
302
+ GetDeviceChainHandler(),
303
+ SetUserPositionHandler(),
304
+ Get64Handler(),
305
+ Set64Handler(),
306
+ CopyFrameBufferHandler(),
307
+ GetEffectHandler(),
308
+ SetEffectHandler(),
309
+ ]