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.
- lifx_emulator/__init__.py +31 -0
- lifx_emulator/__main__.py +607 -0
- lifx_emulator/api.py +1825 -0
- lifx_emulator/async_storage.py +308 -0
- lifx_emulator/constants.py +33 -0
- lifx_emulator/device.py +750 -0
- lifx_emulator/device_states.py +114 -0
- lifx_emulator/factories.py +380 -0
- lifx_emulator/handlers/__init__.py +39 -0
- lifx_emulator/handlers/base.py +49 -0
- lifx_emulator/handlers/device_handlers.py +340 -0
- lifx_emulator/handlers/light_handlers.py +372 -0
- lifx_emulator/handlers/multizone_handlers.py +249 -0
- lifx_emulator/handlers/registry.py +110 -0
- lifx_emulator/handlers/tile_handlers.py +309 -0
- lifx_emulator/observers.py +139 -0
- lifx_emulator/products/__init__.py +28 -0
- lifx_emulator/products/generator.py +771 -0
- lifx_emulator/products/registry.py +1446 -0
- lifx_emulator/products/specs.py +242 -0
- lifx_emulator/products/specs.yml +327 -0
- lifx_emulator/protocol/__init__.py +1 -0
- lifx_emulator/protocol/base.py +334 -0
- lifx_emulator/protocol/const.py +8 -0
- lifx_emulator/protocol/generator.py +1371 -0
- lifx_emulator/protocol/header.py +159 -0
- lifx_emulator/protocol/packets.py +1351 -0
- lifx_emulator/protocol/protocol_types.py +844 -0
- lifx_emulator/protocol/serializer.py +379 -0
- lifx_emulator/scenario_manager.py +402 -0
- lifx_emulator/scenario_persistence.py +206 -0
- lifx_emulator/server.py +482 -0
- lifx_emulator/state_restorer.py +259 -0
- lifx_emulator/state_serializer.py +130 -0
- lifx_emulator/storage_protocol.py +100 -0
- lifx_emulator-1.0.0.dist-info/METADATA +445 -0
- lifx_emulator-1.0.0.dist-info/RECORD +40 -0
- lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
- lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|