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,340 @@
1
+ """Device packet handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from lifx_emulator.handlers.base import PacketHandler
10
+ from lifx_emulator.protocol.packets import Device
11
+ from lifx_emulator.protocol.protocol_types import DeviceService as ProtocolDeviceService
12
+
13
+ if TYPE_CHECKING:
14
+ from lifx_emulator.device import DeviceState
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class GetServiceHandler(PacketHandler):
20
+ """Handle DeviceGetService (2) -> DeviceStateService (3)."""
21
+
22
+ PKT_TYPE = Device.GetService.PKT_TYPE
23
+
24
+ def handle(
25
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
26
+ ) -> list[Any]:
27
+ logger.debug(
28
+ "Sending StateService: %s [%s]",
29
+ ProtocolDeviceService.UDP,
30
+ device_state.port,
31
+ )
32
+ return [
33
+ Device.StateService(
34
+ service=ProtocolDeviceService.UDP, port=device_state.port
35
+ )
36
+ ]
37
+
38
+
39
+ class GetPowerHandler(PacketHandler):
40
+ """Handle DeviceGetPower (20) -> DeviceStatePower (22)."""
41
+
42
+ PKT_TYPE = Device.GetPower.PKT_TYPE
43
+
44
+ def handle(
45
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
46
+ ) -> list[Any]:
47
+ return [Device.StatePower(level=device_state.power_level)]
48
+
49
+
50
+ class SetPowerHandler(PacketHandler):
51
+ """Handle DeviceSetPower (21) -> DeviceStatePower (22)."""
52
+
53
+ PKT_TYPE = Device.SetPower.PKT_TYPE
54
+
55
+ def handle(
56
+ self,
57
+ device_state: DeviceState,
58
+ packet: Device.SetPower | None,
59
+ res_required: bool,
60
+ ) -> list[Any]:
61
+ if packet:
62
+ device_state.power_level = packet.level
63
+ logger.info("Power set to %s", device_state.power_level)
64
+
65
+ if res_required:
66
+ return [Device.StatePower(level=device_state.power_level)]
67
+ return []
68
+
69
+
70
+ class GetLabelHandler(PacketHandler):
71
+ """Handle DeviceGetLabel (23) -> DeviceStateLabel (25)."""
72
+
73
+ PKT_TYPE = Device.GetLabel.PKT_TYPE
74
+
75
+ def handle(
76
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
77
+ ) -> list[Any]:
78
+ label_bytes = device_state.label.encode("utf-8")[:32].ljust(32, b"\x00")
79
+ return [Device.StateLabel(label=label_bytes)]
80
+
81
+
82
+ class SetLabelHandler(PacketHandler):
83
+ """Handle DeviceSetLabel (24) -> DeviceStateLabel (25)."""
84
+
85
+ PKT_TYPE = Device.SetLabel.PKT_TYPE
86
+
87
+ def handle(
88
+ self,
89
+ device_state: DeviceState,
90
+ packet: Device.SetLabel | None,
91
+ res_required: bool,
92
+ ) -> list[Any]:
93
+ if packet:
94
+ device_state.label = packet.label.rstrip(b"\x00").decode(
95
+ "utf-8", errors="replace"
96
+ )
97
+ logger.info("Label set to '%s'", device_state.label)
98
+
99
+ if res_required:
100
+ label_bytes = device_state.label.encode("utf-8")[:32].ljust(32, b"\x00")
101
+ return [Device.StateLabel(label=label_bytes)]
102
+ return []
103
+
104
+
105
+ class GetVersionHandler(PacketHandler):
106
+ """Handle DeviceGetVersion (32) -> DeviceStateVersion (33)."""
107
+
108
+ PKT_TYPE = Device.GetVersion.PKT_TYPE
109
+
110
+ def handle(
111
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
112
+ ) -> list[Any]:
113
+ return [
114
+ Device.StateVersion(
115
+ vendor=device_state.vendor, product=device_state.product
116
+ )
117
+ ]
118
+
119
+
120
+ class GetInfoHandler(PacketHandler):
121
+ """Handle DeviceGetInfo (34) -> DeviceStateInfo (35)."""
122
+
123
+ PKT_TYPE = Device.GetInfo.PKT_TYPE
124
+
125
+ def handle(
126
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
127
+ ) -> list[Any]:
128
+ current_time = int(time.time() * 1e9) # nanoseconds
129
+ return [
130
+ Device.StateInfo(
131
+ time=current_time, uptime=device_state.uptime_ns, downtime=0
132
+ )
133
+ ]
134
+
135
+
136
+ class GetHostFirmwareHandler(PacketHandler):
137
+ """Handle DeviceGetHostFirmware (14) -> DeviceStateHostFirmware (15)."""
138
+
139
+ PKT_TYPE = Device.GetHostFirmware.PKT_TYPE
140
+
141
+ def handle(
142
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
143
+ ) -> list[Any]:
144
+ return [
145
+ Device.StateHostFirmware(
146
+ build=device_state.build_timestamp,
147
+ version_minor=device_state.version_minor,
148
+ version_major=device_state.version_major,
149
+ )
150
+ ]
151
+
152
+
153
+ class GetWifiInfoHandler(PacketHandler):
154
+ """Handle DeviceGetWifiInfo (16) -> DeviceStateWifiInfo (17)."""
155
+
156
+ PKT_TYPE = Device.GetWifiInfo.PKT_TYPE
157
+
158
+ def handle(
159
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
160
+ ) -> list[Any]:
161
+ return [Device.StateWifiInfo(signal=device_state.wifi_signal)]
162
+
163
+
164
+ class GetLocationHandler(PacketHandler):
165
+ """Handle DeviceGetLocation (48) -> DeviceStateLocation (50)."""
166
+
167
+ PKT_TYPE = Device.GetLocation.PKT_TYPE
168
+
169
+ def handle(
170
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
171
+ ) -> list[Any]:
172
+ label_bytes = device_state.location_label.encode("utf-8")[:32].ljust(
173
+ 32, b"\x00"
174
+ )
175
+ return [
176
+ Device.StateLocation(
177
+ location=device_state.location_id,
178
+ label=label_bytes,
179
+ updated_at=device_state.location_updated_at,
180
+ )
181
+ ]
182
+
183
+
184
+ class SetLocationHandler(PacketHandler):
185
+ """Handle DeviceSetLocation (49) -> DeviceStateLocation (50)."""
186
+
187
+ PKT_TYPE = Device.SetLocation.PKT_TYPE
188
+
189
+ def handle(
190
+ self,
191
+ device_state: DeviceState,
192
+ packet: Device.SetLocation | None,
193
+ res_required: bool,
194
+ ) -> list[Any]:
195
+ if packet:
196
+ device_state.location_id = packet.location
197
+ device_state.location_label = packet.label.rstrip(b"\x00").decode(
198
+ "utf-8", errors="replace"
199
+ )
200
+ device_state.location_updated_at = packet.updated_at
201
+ loc_id = packet.location.hex()[:8]
202
+ logger.info(
203
+ "Location set to '%s' (id=%s...)", device_state.location_label, loc_id
204
+ )
205
+
206
+ if res_required:
207
+ label_bytes = device_state.location_label.encode("utf-8")[:32].ljust(
208
+ 32, b"\x00"
209
+ )
210
+ return [
211
+ Device.StateLocation(
212
+ location=device_state.location_id,
213
+ label=label_bytes,
214
+ updated_at=device_state.location_updated_at,
215
+ )
216
+ ]
217
+ return []
218
+
219
+
220
+ class GetGroupHandler(PacketHandler):
221
+ """Handle DeviceGetGroup (51) -> DeviceStateGroup (53)."""
222
+
223
+ PKT_TYPE = Device.GetGroup.PKT_TYPE
224
+
225
+ def handle(
226
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
227
+ ) -> list[Any]:
228
+ label_bytes = device_state.group_label.encode("utf-8")[:32].ljust(32, b"\x00")
229
+ return [
230
+ Device.StateGroup(
231
+ group=device_state.group_id,
232
+ label=label_bytes,
233
+ updated_at=device_state.group_updated_at,
234
+ )
235
+ ]
236
+
237
+
238
+ class SetGroupHandler(PacketHandler):
239
+ """Handle DeviceSetGroup (52) -> DeviceStateGroup (53)."""
240
+
241
+ PKT_TYPE = Device.SetGroup.PKT_TYPE
242
+
243
+ def handle(
244
+ self,
245
+ device_state: DeviceState,
246
+ packet: Device.SetGroup | None,
247
+ res_required: bool,
248
+ ) -> list[Any]:
249
+ if packet:
250
+ device_state.group_id = packet.group
251
+ device_state.group_label = packet.label.rstrip(b"\x00").decode(
252
+ "utf-8", errors="replace"
253
+ )
254
+ device_state.group_updated_at = packet.updated_at
255
+ grp_id = packet.group.hex()[:8]
256
+ logger.info(
257
+ "Group set to '%s' (id=%s...)", device_state.group_label, grp_id
258
+ )
259
+
260
+ if res_required:
261
+ label_bytes = device_state.group_label.encode("utf-8")[:32].ljust(
262
+ 32, b"\x00"
263
+ )
264
+ return [
265
+ Device.StateGroup(
266
+ group=device_state.group_id,
267
+ label=label_bytes,
268
+ updated_at=device_state.group_updated_at,
269
+ )
270
+ ]
271
+ return []
272
+
273
+
274
+ class EchoRequestHandler(PacketHandler):
275
+ """Handle DeviceEchoRequest (58) -> DeviceEchoResponse (59)."""
276
+
277
+ PKT_TYPE = Device.EchoRequest.PKT_TYPE
278
+
279
+ def handle(
280
+ self,
281
+ device_state: DeviceState,
282
+ packet: Device.EchoRequest | None,
283
+ res_required: bool,
284
+ ) -> list[Any]:
285
+ payload = packet.payload if packet else b"\x00" * 64
286
+ return [Device.EchoResponse(payload=payload[:64].ljust(64, b"\x00"))]
287
+
288
+
289
+ class GetWifiFirmwareHandler(PacketHandler):
290
+ """Handle DeviceGetWifiFirmware (18) -> DeviceStateWifiFirmware (19)."""
291
+
292
+ PKT_TYPE = Device.GetWifiFirmware.PKT_TYPE
293
+
294
+ def handle(
295
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
296
+ ) -> list[Any]:
297
+ build = int(time.time()) - 1000000000 # Example build timestamp
298
+ return [
299
+ Device.StateWifiFirmware(
300
+ build=build,
301
+ version_minor=device_state.version_minor,
302
+ version_major=device_state.version_major,
303
+ )
304
+ ]
305
+
306
+
307
+ class SetRebootHandler(PacketHandler):
308
+ """Handle DeviceSetReboot (38) - just acknowledge, don't actually reboot."""
309
+
310
+ PKT_TYPE = Device.SetReboot.PKT_TYPE
311
+
312
+ def handle(
313
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
314
+ ) -> list[Any]:
315
+ serial = device_state.serial
316
+ logger.info("Device %s: Received reboot request (ignored in emulator)", serial)
317
+ # In a real device, this would trigger a reboot
318
+ # In emulator, we just acknowledge it
319
+ return []
320
+
321
+
322
+ # List of all device handlers for easy registration
323
+ ALL_DEVICE_HANDLERS = [
324
+ GetServiceHandler(),
325
+ GetPowerHandler(),
326
+ SetPowerHandler(),
327
+ GetLabelHandler(),
328
+ SetLabelHandler(),
329
+ GetVersionHandler(),
330
+ GetInfoHandler(),
331
+ GetHostFirmwareHandler(),
332
+ GetWifiInfoHandler(),
333
+ GetLocationHandler(),
334
+ SetLocationHandler(),
335
+ GetGroupHandler(),
336
+ SetGroupHandler(),
337
+ EchoRequestHandler(),
338
+ GetWifiFirmwareHandler(),
339
+ SetRebootHandler(),
340
+ ]
@@ -0,0 +1,372 @@
1
+ """Light 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 Light
10
+ from lifx_emulator.protocol.protocol_types import LightLastHevCycleResult
11
+
12
+ if TYPE_CHECKING:
13
+ from lifx_emulator.device import DeviceState
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class GetColorHandler(PacketHandler):
19
+ """Handle LightGet (101) -> LightState (107)."""
20
+
21
+ PKT_TYPE = Light.GetColor.PKT_TYPE
22
+
23
+ def handle(
24
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
25
+ ) -> list[Any]:
26
+ label_bytes = device_state.label.encode("utf-8")[:32].ljust(32, b"\x00")
27
+ return [
28
+ Light.StateColor(
29
+ color=device_state.color,
30
+ power=device_state.power_level,
31
+ label=label_bytes,
32
+ )
33
+ ]
34
+
35
+
36
+ class SetColorHandler(PacketHandler):
37
+ """Handle LightSetColor (102) -> LightState (107)."""
38
+
39
+ PKT_TYPE = Light.SetColor.PKT_TYPE
40
+
41
+ def handle(
42
+ self,
43
+ device_state: DeviceState,
44
+ packet: Light.SetColor | None,
45
+ res_required: bool,
46
+ ) -> list[Any]:
47
+ if packet:
48
+ device_state.color = packet.color
49
+ c = packet.color
50
+ logger.info(
51
+ f"Color set to HSBK({c.hue}, {c.saturation}, "
52
+ f"{c.brightness}, {c.kelvin}), duration={packet.duration}ms"
53
+ )
54
+
55
+ if res_required:
56
+ label_bytes = device_state.label.encode("utf-8")[:32].ljust(32, b"\x00")
57
+ return [
58
+ Light.StateColor(
59
+ color=device_state.color,
60
+ power=device_state.power_level,
61
+ label=label_bytes,
62
+ )
63
+ ]
64
+ return []
65
+
66
+
67
+ class GetPowerHandler(PacketHandler):
68
+ """Handle LightGetPower (116) -> LightStatePower (118)."""
69
+
70
+ PKT_TYPE = Light.GetPower.PKT_TYPE
71
+
72
+ def handle(
73
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
74
+ ) -> list[Any]:
75
+ return [Light.StatePower(level=device_state.power_level)]
76
+
77
+
78
+ class SetPowerHandler(PacketHandler):
79
+ """Handle LightSetPower (117) -> LightStatePower (118)."""
80
+
81
+ PKT_TYPE = Light.SetPower.PKT_TYPE
82
+
83
+ def handle(
84
+ self,
85
+ device_state: DeviceState,
86
+ packet: Light.SetPower | None,
87
+ res_required: bool,
88
+ ) -> list[Any]:
89
+ if packet:
90
+ device_state.power_level = packet.level
91
+ logger.info(
92
+ f"Light power set to {packet.level}, duration={packet.duration}ms"
93
+ )
94
+
95
+ if res_required:
96
+ return [Light.StatePower(level=device_state.power_level)]
97
+ return []
98
+
99
+
100
+ class SetWaveformHandler(PacketHandler):
101
+ """Handle LightSetWaveform (103) -> LightState (107)."""
102
+
103
+ PKT_TYPE = Light.SetWaveform.PKT_TYPE
104
+
105
+ def handle(
106
+ self,
107
+ device_state: DeviceState,
108
+ packet: Light.SetWaveform | None,
109
+ res_required: bool,
110
+ ) -> list[Any]:
111
+ if packet:
112
+ # Store waveform state
113
+ device_state.waveform_active = True
114
+ device_state.waveform_transient = packet.transient
115
+ device_state.waveform_color = packet.color
116
+ device_state.waveform_period_ms = packet.period
117
+ device_state.waveform_cycles = packet.cycles
118
+ device_state.waveform_skew_ratio = packet.skew_ratio
119
+ device_state.waveform_type = int(packet.waveform)
120
+
121
+ # If not transient, update the color state
122
+ if not packet.transient:
123
+ device_state.color = packet.color
124
+
125
+ logger.info(
126
+ f"Waveform set: type={packet.waveform}, "
127
+ f"transient={packet.transient}, period={packet.period}ms, "
128
+ f"cycles={packet.cycles}, skew={packet.skew_ratio}"
129
+ )
130
+
131
+ if res_required:
132
+ label_bytes = device_state.label.encode("utf-8")[:32].ljust(32, b"\x00")
133
+ return [
134
+ Light.StateColor(
135
+ color=device_state.color,
136
+ power=device_state.power_level,
137
+ label=label_bytes,
138
+ )
139
+ ]
140
+ return []
141
+
142
+
143
+ class SetWaveformOptionalHandler(PacketHandler):
144
+ """Handle LightSetWaveformOptional (119) -> LightState (107)."""
145
+
146
+ PKT_TYPE = Light.SetWaveformOptional.PKT_TYPE
147
+
148
+ def handle(
149
+ self,
150
+ device_state: DeviceState,
151
+ packet: Light.SetWaveformOptional | None,
152
+ res_required: bool,
153
+ ) -> list[Any]:
154
+ if packet:
155
+ # Store waveform state
156
+ device_state.waveform_active = True
157
+ device_state.waveform_transient = packet.transient
158
+ device_state.waveform_period_ms = packet.period
159
+ device_state.waveform_cycles = packet.cycles
160
+ device_state.waveform_skew_ratio = packet.skew_ratio
161
+ device_state.waveform_type = int(packet.waveform)
162
+
163
+ # Apply color components selectively based on flags
164
+ if not packet.transient:
165
+ if packet.set_hue:
166
+ device_state.color.hue = packet.color.hue
167
+ if packet.set_saturation:
168
+ device_state.color.saturation = packet.color.saturation
169
+ if packet.set_brightness:
170
+ device_state.color.brightness = packet.color.brightness
171
+ if packet.set_kelvin:
172
+ device_state.color.kelvin = packet.color.kelvin
173
+
174
+ # Store the waveform color (all components)
175
+ device_state.waveform_color = packet.color
176
+
177
+ logger.info(
178
+ f"Waveform optional set: type={packet.waveform}, "
179
+ f"transient={packet.transient}, period={packet.period}ms, "
180
+ f"cycles={packet.cycles}, components=[H:{packet.set_hue},"
181
+ f"S:{packet.set_saturation},B:{packet.set_brightness},"
182
+ f"K:{packet.set_kelvin}]"
183
+ )
184
+
185
+ if res_required:
186
+ label_bytes = device_state.label.encode("utf-8")[:32].ljust(32, b"\x00")
187
+ return [
188
+ Light.StateColor(
189
+ color=device_state.color,
190
+ power=device_state.power_level,
191
+ label=label_bytes,
192
+ )
193
+ ]
194
+ return []
195
+
196
+
197
+ class GetInfraredHandler(PacketHandler):
198
+ """Handle LightGetInfrared (120) -> LightStateInfrared (121)."""
199
+
200
+ PKT_TYPE = Light.GetInfrared.PKT_TYPE
201
+
202
+ def handle(
203
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
204
+ ) -> list[Any]:
205
+ if not device_state.has_infrared:
206
+ return []
207
+ return [Light.StateInfrared(brightness=device_state.infrared_brightness)]
208
+
209
+
210
+ class SetInfraredHandler(PacketHandler):
211
+ """Handle LightSetInfrared (122) -> LightStateInfrared (121)."""
212
+
213
+ PKT_TYPE = Light.SetInfrared.PKT_TYPE
214
+
215
+ def handle(
216
+ self,
217
+ device_state: DeviceState,
218
+ packet: Light.SetInfrared | None,
219
+ res_required: bool,
220
+ ) -> list[Any]:
221
+ if not device_state.has_infrared:
222
+ return []
223
+ if packet:
224
+ device_state.infrared_brightness = packet.brightness
225
+ logger.info("Infrared brightness set to %s", packet.brightness)
226
+
227
+ if res_required:
228
+ return [Light.StateInfrared(brightness=device_state.infrared_brightness)]
229
+ return []
230
+
231
+
232
+ class GetHevCycleHandler(PacketHandler):
233
+ """Handle LightGetHevCycle (142) -> LightStateHevCycle (144)."""
234
+
235
+ PKT_TYPE = Light.GetHevCycle.PKT_TYPE
236
+
237
+ def handle(
238
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
239
+ ) -> list[Any]:
240
+ if not device_state.has_hev:
241
+ return []
242
+ return [
243
+ Light.StateHevCycle(
244
+ duration_s=device_state.hev_cycle_duration_s,
245
+ remaining_s=device_state.hev_cycle_remaining_s,
246
+ last_power=device_state.hev_cycle_last_power,
247
+ )
248
+ ]
249
+
250
+
251
+ class SetHevCycleHandler(PacketHandler):
252
+ """Handle LightSetHevCycle (143) -> LightStateHevCycle (144)."""
253
+
254
+ PKT_TYPE = Light.SetHevCycle.PKT_TYPE
255
+
256
+ def handle(
257
+ self,
258
+ device_state: DeviceState,
259
+ packet: Light.SetHevCycle | None,
260
+ res_required: bool,
261
+ ) -> list[Any]:
262
+ if not device_state.has_hev:
263
+ return []
264
+ if packet:
265
+ device_state.hev_cycle_duration_s = packet.duration_s
266
+ if packet.enable:
267
+ device_state.hev_cycle_remaining_s = packet.duration_s
268
+ else:
269
+ device_state.hev_cycle_remaining_s = 0
270
+ logger.info(
271
+ f"HEV cycle set: enable={packet.enable}, duration={packet.duration_s}s"
272
+ )
273
+
274
+ if res_required:
275
+ return [
276
+ Light.StateHevCycle(
277
+ duration_s=device_state.hev_cycle_duration_s,
278
+ remaining_s=device_state.hev_cycle_remaining_s,
279
+ last_power=device_state.hev_cycle_last_power,
280
+ )
281
+ ]
282
+ return []
283
+
284
+
285
+ class GetHevCycleConfigurationHandler(PacketHandler):
286
+ """Handle LightGetHevCycleConfiguration (145).
287
+
288
+ Returns LightStateHevCycleConfiguration (147).
289
+ """
290
+
291
+ PKT_TYPE = Light.GetHevCycleConfiguration.PKT_TYPE
292
+
293
+ def handle(
294
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
295
+ ) -> list[Any]:
296
+ if not device_state.has_hev:
297
+ return []
298
+ return [
299
+ Light.StateHevCycleConfiguration(
300
+ indication=device_state.hev_indication,
301
+ duration_s=device_state.hev_cycle_duration_s,
302
+ )
303
+ ]
304
+
305
+
306
+ class SetHevCycleConfigurationHandler(PacketHandler):
307
+ """Handle LightSetHevCycleConfiguration (146).
308
+
309
+ Returns LightStateHevCycleConfiguration (147).
310
+ """
311
+
312
+ PKT_TYPE = Light.SetHevCycleConfiguration.PKT_TYPE
313
+
314
+ def handle(
315
+ self,
316
+ device_state: DeviceState,
317
+ packet: Light.SetHevCycleConfiguration | None,
318
+ res_required: bool,
319
+ ) -> list[Any]:
320
+ if not device_state.has_hev:
321
+ return []
322
+ if packet:
323
+ device_state.hev_indication = packet.indication
324
+ device_state.hev_cycle_duration_s = packet.duration_s
325
+ logger.info(
326
+ f"HEV config set: indication={packet.indication}, "
327
+ f"duration={packet.duration_s}s"
328
+ )
329
+
330
+ if res_required:
331
+ return [
332
+ Light.StateHevCycleConfiguration(
333
+ indication=device_state.hev_indication,
334
+ duration_s=device_state.hev_cycle_duration_s,
335
+ )
336
+ ]
337
+ return []
338
+
339
+
340
+ class GetLastHevCycleResultHandler(PacketHandler):
341
+ """Handle LightGetLastHevCycleResult (148) -> LightStateLastHevCycleResult (149)."""
342
+
343
+ PKT_TYPE = Light.GetLastHevCycleResult.PKT_TYPE
344
+
345
+ def handle(
346
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
347
+ ) -> list[Any]:
348
+ if not device_state.has_hev:
349
+ return []
350
+ return [
351
+ Light.StateLastHevCycleResult(
352
+ result=LightLastHevCycleResult(device_state.hev_last_result)
353
+ )
354
+ ]
355
+
356
+
357
+ # List of all light handlers for easy registration
358
+ ALL_LIGHT_HANDLERS = [
359
+ GetColorHandler(),
360
+ SetColorHandler(),
361
+ GetPowerHandler(),
362
+ SetPowerHandler(),
363
+ SetWaveformHandler(),
364
+ SetWaveformOptionalHandler(),
365
+ GetInfraredHandler(),
366
+ SetInfraredHandler(),
367
+ GetHevCycleHandler(),
368
+ SetHevCycleHandler(),
369
+ GetHevCycleConfigurationHandler(),
370
+ SetHevCycleConfigurationHandler(),
371
+ GetLastHevCycleResultHandler(),
372
+ ]