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,750 @@
1
+ """Device state and emulated device implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import copy
7
+ import logging
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from lifx_emulator.constants import LIFX_HEADER_SIZE
13
+ from lifx_emulator.device_states import (
14
+ CoreDeviceState,
15
+ GroupState,
16
+ HevState,
17
+ InfraredState,
18
+ LocationState,
19
+ MatrixState,
20
+ MultiZoneState,
21
+ NetworkState,
22
+ WaveformState,
23
+ )
24
+ from lifx_emulator.handlers import HandlerRegistry, create_default_registry
25
+ from lifx_emulator.protocol.header import LifxHeader
26
+ from lifx_emulator.protocol.packets import (
27
+ Device,
28
+ )
29
+ from lifx_emulator.protocol.protocol_types import LightHsbk
30
+ from lifx_emulator.scenario_manager import (
31
+ HierarchicalScenarioManager,
32
+ ScenarioConfig,
33
+ get_device_type,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Forward declaration for type hinting
39
+ TYPE_CHECKING = False
40
+ if TYPE_CHECKING:
41
+ from lifx_emulator.async_storage import AsyncDeviceStorage
42
+
43
+
44
+ @dataclass
45
+ class DeviceState:
46
+ """Composed device state following Single Responsibility Principle.
47
+
48
+ Each aspect of device state is managed by a focused sub-state object.
49
+ """
50
+
51
+ core: CoreDeviceState
52
+ network: NetworkState
53
+ location: LocationState
54
+ group: GroupState
55
+ waveform: WaveformState
56
+
57
+ # Optional capability-specific state
58
+ infrared: InfraredState | None = None
59
+ hev: HevState | None = None
60
+ multizone: MultiZoneState | None = None
61
+ matrix: MatrixState | None = None
62
+
63
+ # Capability flags (kept for convenience)
64
+ has_color: bool = True
65
+ has_infrared: bool = False
66
+ has_multizone: bool = False
67
+ has_extended_multizone: bool = False
68
+ has_matrix: bool = False
69
+ has_hev: bool = False
70
+
71
+ def get_target_bytes(self) -> bytes:
72
+ """Get target bytes for this device"""
73
+ return bytes.fromhex(self.core.serial) + b"\x00\x00"
74
+
75
+ # Convenience properties for commonly accessed core fields
76
+ @property
77
+ def serial(self) -> str:
78
+ return self.core.serial
79
+
80
+ @property
81
+ def label(self) -> str:
82
+ return self.core.label
83
+
84
+ @label.setter
85
+ def label(self, value: str):
86
+ self.core.label = value
87
+
88
+ @property
89
+ def power_level(self) -> int:
90
+ return self.core.power_level
91
+
92
+ @power_level.setter
93
+ def power_level(self, value: int):
94
+ self.core.power_level = value
95
+
96
+ @property
97
+ def color(self) -> LightHsbk:
98
+ return self.core.color
99
+
100
+ @color.setter
101
+ def color(self, value: LightHsbk):
102
+ self.core.color = value
103
+
104
+ @property
105
+ def vendor(self) -> int:
106
+ return self.core.vendor
107
+
108
+ @property
109
+ def product(self) -> int:
110
+ return self.core.product
111
+
112
+ @property
113
+ def version_major(self) -> int:
114
+ return self.core.version_major
115
+
116
+ @version_major.setter
117
+ def version_major(self, value: int):
118
+ self.core.version_major = value
119
+
120
+ @property
121
+ def version_minor(self) -> int:
122
+ return self.core.version_minor
123
+
124
+ @version_minor.setter
125
+ def version_minor(self, value: int):
126
+ self.core.version_minor = value
127
+
128
+ @property
129
+ def build_timestamp(self) -> int:
130
+ return self.core.build_timestamp
131
+
132
+ @property
133
+ def uptime_ns(self) -> int:
134
+ return self.core.uptime_ns
135
+
136
+ @uptime_ns.setter
137
+ def uptime_ns(self, value: int):
138
+ self.core.uptime_ns = value
139
+
140
+ @property
141
+ def mac_address(self) -> bytes:
142
+ return self.core.mac_address
143
+
144
+ @property
145
+ def port(self) -> int:
146
+ return self.core.port
147
+
148
+ @port.setter
149
+ def port(self, value: int):
150
+ self.core.port = value
151
+
152
+ # Network properties
153
+ @property
154
+ def wifi_signal(self) -> float:
155
+ return self.network.wifi_signal
156
+
157
+ # Location properties
158
+ @property
159
+ def location_id(self) -> bytes:
160
+ return self.location.location_id
161
+
162
+ @location_id.setter
163
+ def location_id(self, value: bytes):
164
+ self.location.location_id = value
165
+
166
+ @property
167
+ def location_label(self) -> str:
168
+ return self.location.location_label
169
+
170
+ @location_label.setter
171
+ def location_label(self, value: str):
172
+ self.location.location_label = value
173
+
174
+ @property
175
+ def location_updated_at(self) -> int:
176
+ return self.location.location_updated_at
177
+
178
+ @location_updated_at.setter
179
+ def location_updated_at(self, value: int):
180
+ self.location.location_updated_at = value
181
+
182
+ # Group properties
183
+ @property
184
+ def group_id(self) -> bytes:
185
+ return self.group.group_id
186
+
187
+ @group_id.setter
188
+ def group_id(self, value: bytes):
189
+ self.group.group_id = value
190
+
191
+ @property
192
+ def group_label(self) -> str:
193
+ return self.group.group_label
194
+
195
+ @group_label.setter
196
+ def group_label(self, value: str):
197
+ self.group.group_label = value
198
+
199
+ @property
200
+ def group_updated_at(self) -> int:
201
+ return self.group.group_updated_at
202
+
203
+ @group_updated_at.setter
204
+ def group_updated_at(self, value: int):
205
+ self.group.group_updated_at = value
206
+
207
+ # Waveform properties
208
+ @property
209
+ def waveform_active(self) -> bool:
210
+ return self.waveform.waveform_active
211
+
212
+ @waveform_active.setter
213
+ def waveform_active(self, value: bool):
214
+ self.waveform.waveform_active = value
215
+
216
+ @property
217
+ def waveform_type(self) -> int:
218
+ return self.waveform.waveform_type
219
+
220
+ @waveform_type.setter
221
+ def waveform_type(self, value: int):
222
+ self.waveform.waveform_type = value
223
+
224
+ @property
225
+ def waveform_transient(self) -> bool:
226
+ return self.waveform.waveform_transient
227
+
228
+ @waveform_transient.setter
229
+ def waveform_transient(self, value: bool):
230
+ self.waveform.waveform_transient = value
231
+
232
+ @property
233
+ def waveform_color(self) -> LightHsbk:
234
+ return self.waveform.waveform_color
235
+
236
+ @waveform_color.setter
237
+ def waveform_color(self, value: LightHsbk):
238
+ self.waveform.waveform_color = value
239
+
240
+ @property
241
+ def waveform_period_ms(self) -> int:
242
+ return self.waveform.waveform_period_ms
243
+
244
+ @waveform_period_ms.setter
245
+ def waveform_period_ms(self, value: int):
246
+ self.waveform.waveform_period_ms = value
247
+
248
+ @property
249
+ def waveform_cycles(self) -> float:
250
+ return self.waveform.waveform_cycles
251
+
252
+ @waveform_cycles.setter
253
+ def waveform_cycles(self, value: float):
254
+ self.waveform.waveform_cycles = value
255
+
256
+ @property
257
+ def waveform_duty_cycle(self) -> int:
258
+ return self.waveform.waveform_duty_cycle
259
+
260
+ @waveform_duty_cycle.setter
261
+ def waveform_duty_cycle(self, value: int):
262
+ self.waveform.waveform_duty_cycle = value
263
+
264
+ @property
265
+ def waveform_skew_ratio(self) -> int:
266
+ return self.waveform.waveform_skew_ratio
267
+
268
+ @waveform_skew_ratio.setter
269
+ def waveform_skew_ratio(self, value: int):
270
+ self.waveform.waveform_skew_ratio = value
271
+
272
+ # Infrared properties
273
+ @property
274
+ def infrared_brightness(self) -> int:
275
+ if self.infrared is None:
276
+ return 0
277
+ return self.infrared.infrared_brightness
278
+
279
+ @infrared_brightness.setter
280
+ def infrared_brightness(self, value: int):
281
+ if self.infrared is not None:
282
+ self.infrared.infrared_brightness = value
283
+
284
+ # HEV properties
285
+ @property
286
+ def hev_cycle_duration_s(self) -> int:
287
+ if self.hev is None:
288
+ return 0
289
+ return self.hev.hev_cycle_duration_s
290
+
291
+ @hev_cycle_duration_s.setter
292
+ def hev_cycle_duration_s(self, value: int):
293
+ if self.hev is not None:
294
+ self.hev.hev_cycle_duration_s = value
295
+
296
+ @property
297
+ def hev_cycle_remaining_s(self) -> int:
298
+ if self.hev is None:
299
+ return 0
300
+ return self.hev.hev_cycle_remaining_s
301
+
302
+ @hev_cycle_remaining_s.setter
303
+ def hev_cycle_remaining_s(self, value: int):
304
+ if self.hev is not None:
305
+ self.hev.hev_cycle_remaining_s = value
306
+
307
+ @property
308
+ def hev_cycle_last_power(self) -> bool:
309
+ if self.hev is None:
310
+ return False
311
+ return self.hev.hev_cycle_last_power
312
+
313
+ @hev_cycle_last_power.setter
314
+ def hev_cycle_last_power(self, value: bool):
315
+ if self.hev is not None:
316
+ self.hev.hev_cycle_last_power = value
317
+
318
+ @property
319
+ def hev_indication(self) -> bool:
320
+ if self.hev is None:
321
+ return False
322
+ return self.hev.hev_indication
323
+
324
+ @hev_indication.setter
325
+ def hev_indication(self, value: bool):
326
+ if self.hev is not None:
327
+ self.hev.hev_indication = value
328
+
329
+ @property
330
+ def hev_last_result(self) -> int:
331
+ if self.hev is None:
332
+ return 0
333
+ return self.hev.hev_last_result
334
+
335
+ @hev_last_result.setter
336
+ def hev_last_result(self, value: int):
337
+ if self.hev is not None:
338
+ self.hev.hev_last_result = value
339
+
340
+ # MultiZone properties
341
+ @property
342
+ def zone_count(self) -> int:
343
+ if self.multizone is None:
344
+ return 0
345
+ return self.multizone.zone_count
346
+
347
+ @property
348
+ def zone_colors(self) -> list[LightHsbk]:
349
+ if self.multizone is None:
350
+ return []
351
+ return self.multizone.zone_colors
352
+
353
+ @zone_colors.setter
354
+ def zone_colors(self, value: list[LightHsbk]):
355
+ if self.multizone is not None:
356
+ self.multizone.zone_colors = value
357
+
358
+ @property
359
+ def multizone_effect_type(self) -> int:
360
+ if self.multizone is None:
361
+ return 0
362
+ return self.multizone.effect_type
363
+
364
+ @multizone_effect_type.setter
365
+ def multizone_effect_type(self, value: int):
366
+ if self.multizone is not None:
367
+ self.multizone.effect_type = value
368
+
369
+ @property
370
+ def multizone_effect_speed(self) -> int:
371
+ if self.multizone is None:
372
+ return 0
373
+ return self.multizone.effect_speed
374
+
375
+ @multizone_effect_speed.setter
376
+ def multizone_effect_speed(self, value: int):
377
+ if self.multizone is not None:
378
+ self.multizone.effect_speed = value
379
+
380
+ # Matrix (Tile) properties
381
+ @property
382
+ def tile_count(self) -> int:
383
+ if self.matrix is None:
384
+ return 0
385
+ return self.matrix.tile_count
386
+
387
+ @property
388
+ def tile_devices(self) -> list[dict[str, Any]]:
389
+ if self.matrix is None:
390
+ return []
391
+ return self.matrix.tile_devices
392
+
393
+ @tile_devices.setter
394
+ def tile_devices(self, value: list[dict[str, Any]]):
395
+ if self.matrix is not None:
396
+ self.matrix.tile_devices = value
397
+
398
+ @property
399
+ def tile_width(self) -> int:
400
+ if self.matrix is None:
401
+ return 8
402
+ return self.matrix.tile_width
403
+
404
+ @property
405
+ def tile_height(self) -> int:
406
+ if self.matrix is None:
407
+ return 8
408
+ return self.matrix.tile_height
409
+
410
+ @property
411
+ def tile_effect_type(self) -> int:
412
+ if self.matrix is None:
413
+ return 0
414
+ return self.matrix.effect_type
415
+
416
+ @tile_effect_type.setter
417
+ def tile_effect_type(self, value: int):
418
+ if self.matrix is not None:
419
+ self.matrix.effect_type = value
420
+
421
+ @property
422
+ def tile_effect_speed(self) -> int:
423
+ if self.matrix is None:
424
+ return 0
425
+ return self.matrix.effect_speed
426
+
427
+ @tile_effect_speed.setter
428
+ def tile_effect_speed(self, value: int):
429
+ if self.matrix is not None:
430
+ self.matrix.effect_speed = value
431
+
432
+ @property
433
+ def tile_effect_palette_count(self) -> int:
434
+ if self.matrix is None:
435
+ return 0
436
+ return self.matrix.effect_palette_count
437
+
438
+ @tile_effect_palette_count.setter
439
+ def tile_effect_palette_count(self, value: int):
440
+ if self.matrix is not None:
441
+ self.matrix.effect_palette_count = value
442
+
443
+ @property
444
+ def tile_effect_palette(self) -> list[LightHsbk]:
445
+ if self.matrix is None:
446
+ return []
447
+ return self.matrix.effect_palette
448
+
449
+ @tile_effect_palette.setter
450
+ def tile_effect_palette(self, value: list[LightHsbk]):
451
+ if self.matrix is not None:
452
+ self.matrix.effect_palette = value
453
+
454
+
455
+ class EmulatedLifxDevice:
456
+ """Emulated LIFX device with configurable scenarios"""
457
+
458
+ """Simulated LIFX device with configurable scenarios"""
459
+
460
+ def __init__(
461
+ self,
462
+ device_state: DeviceState,
463
+ storage: AsyncDeviceStorage | None = None,
464
+ handler_registry: HandlerRegistry | None = None,
465
+ scenario_manager: HierarchicalScenarioManager | None = None,
466
+ ):
467
+ self.state = device_state
468
+ # Use provided scenario manager or create a default empty one
469
+ if scenario_manager is not None:
470
+ self.scenario_manager = scenario_manager
471
+ else:
472
+ self.scenario_manager = HierarchicalScenarioManager()
473
+ self.start_time = time.time()
474
+ self.storage = storage
475
+
476
+ # Scenario caching for performance (HierarchicalScenarioManager only)
477
+ self._cached_scenario: ScenarioConfig | None = None
478
+
479
+ # Track background save tasks to prevent garbage collection
480
+ self.background_save_tasks: set[asyncio.Task] = set()
481
+
482
+ # Use provided registry or create default one
483
+ self.handlers = handler_registry or create_default_registry()
484
+
485
+ # Pre-allocate response header template for performance (10-15% gain)
486
+ # This avoids creating a new LifxHeader object for every response
487
+ self._response_header_template = LifxHeader(
488
+ source=0,
489
+ target=self.state.get_target_bytes(),
490
+ sequence=0,
491
+ tagged=False,
492
+ pkt_type=0,
493
+ size=0,
494
+ )
495
+
496
+ # Initialize multizone colors if needed
497
+ # Note: State restoration is handled by StateRestorer in factories
498
+ if self.state.has_multizone and self.state.zone_count > 0:
499
+ if not self.state.zone_colors:
500
+ # Initialize with rainbow pattern
501
+ self.state.zone_colors = []
502
+ for i in range(self.state.zone_count):
503
+ hue = int((i / self.state.zone_count) * 65535)
504
+ self.state.zone_colors.append(
505
+ LightHsbk(
506
+ hue=hue, saturation=65535, brightness=32768, kelvin=3500
507
+ )
508
+ )
509
+
510
+ # Initialize tile state if needed
511
+ # Note: Saved tile data is restored by StateRestorer in factories
512
+ if self.state.has_matrix and self.state.tile_count > 0:
513
+ if not self.state.tile_devices:
514
+ for i in range(self.state.tile_count):
515
+ pixels = self.state.tile_width * self.state.tile_height
516
+ tile_colors = [
517
+ LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
518
+ for _ in range(pixels)
519
+ ]
520
+
521
+ self.state.tile_devices.append(
522
+ {
523
+ "accel_meas_x": 0,
524
+ "accel_meas_y": 0,
525
+ "accel_meas_z": 0,
526
+ "user_x": float(i * self.state.tile_width),
527
+ "user_y": 0.0,
528
+ "width": self.state.tile_width,
529
+ "height": self.state.tile_height,
530
+ "device_version_vendor": 1,
531
+ "device_version_product": self.state.product,
532
+ "firmware_build": int(time.time()),
533
+ "firmware_version_minor": 70,
534
+ "firmware_version_major": 3,
535
+ "colors": tile_colors,
536
+ }
537
+ )
538
+
539
+ # Save initial state if persistence is enabled
540
+ # This ensures newly created devices are immediately persisted
541
+ if self.storage:
542
+ self._save_state()
543
+
544
+ def get_uptime_ns(self) -> int:
545
+ """Calculate current uptime in nanoseconds"""
546
+ return int((time.time() - self.start_time) * 1e9)
547
+
548
+ def _save_state(self) -> None:
549
+ """Save device state asynchronously (non-blocking).
550
+
551
+ Creates a background task to save state without blocking the event loop.
552
+ The task is tracked to prevent garbage collection.
553
+
554
+ Note: Only AsyncDeviceStorage is supported in production. For testing,
555
+ you can still use DeviceStorage, but it will log a warning as it blocks.
556
+ """
557
+ if not self.storage:
558
+ return
559
+
560
+ try:
561
+ loop = asyncio.get_running_loop()
562
+ task = loop.create_task(self.storage.save_device_state(self.state))
563
+ self._track_save_task(task)
564
+ except RuntimeError:
565
+ # No event loop (shouldn't happen in normal operation)
566
+ logger.error("Cannot save state for %s: no event loop", self.state.serial)
567
+
568
+ def _track_save_task(self, task: asyncio.Task) -> None:
569
+ """Track background save task to prevent garbage collection.
570
+
571
+ Args:
572
+ task: Save task to track
573
+ """
574
+ self.background_save_tasks.add(task)
575
+ task.add_done_callback(self.background_save_tasks.discard)
576
+
577
+ def _get_resolved_scenario(self) -> ScenarioConfig:
578
+ """Get resolved scenario configuration with caching.
579
+
580
+ Resolves scenario from all applicable scopes and caches the result
581
+ for performance.
582
+
583
+ Returns:
584
+ ScenarioConfig with resolved settings
585
+ """
586
+ if self._cached_scenario is not None:
587
+ return self._cached_scenario
588
+
589
+ # Resolve scenario with hierarchical scoping
590
+ self._cached_scenario = self.scenario_manager.get_scenario_for_device(
591
+ serial=self.state.serial,
592
+ device_type=get_device_type(self),
593
+ location=self.state.location_label,
594
+ group=self.state.group_label,
595
+ )
596
+ return self._cached_scenario
597
+
598
+ def invalidate_scenario_cache(self) -> None:
599
+ """Invalidate cached scenario configuration.
600
+
601
+ Call this when scenarios are updated at runtime to force
602
+ recalculation on the next packet.
603
+ """
604
+ self._cached_scenario = None
605
+
606
+ def _create_response_header(
607
+ self, source: int, sequence: int, pkt_type: int, payload_size: int
608
+ ) -> LifxHeader:
609
+ """Create response header using pre-allocated template (performance).
610
+
611
+ This method uses a pre-allocated template and creates a shallow copy,
612
+ then updates the fields. This avoids full __init__ and __post_init__
613
+ overhead while ensuring each response gets its own header object,
614
+ providing ~10% improvement in response generation.
615
+
616
+ Args:
617
+ source: Source identifier from request
618
+ sequence: Sequence number from request
619
+ pkt_type: Packet type for response
620
+ payload_size: Size of packed payload in bytes
621
+
622
+ Returns:
623
+ Configured LifxHeader ready to use
624
+ """
625
+ # Shallow copy of template is faster than full construction with validation
626
+ header = copy.copy(self._response_header_template)
627
+ # Update fields for this specific response
628
+ header.source = source
629
+ header.sequence = sequence
630
+ header.pkt_type = pkt_type
631
+ header.size = LIFX_HEADER_SIZE + payload_size
632
+ return header
633
+
634
+ def process_packet(
635
+ self, header: LifxHeader, packet: Any | None
636
+ ) -> list[tuple[LifxHeader, Any]]:
637
+ """Process incoming packet and return response packets"""
638
+ responses = []
639
+
640
+ # Get resolved scenario configuration (cached for performance)
641
+ scenario = self._get_resolved_scenario()
642
+
643
+ # Check if packet should be dropped (with probabilistic drops)
644
+ if not self.scenario_manager.should_respond(header.pkt_type, scenario):
645
+ logger.info("Dropping packet type %s per scenario", header.pkt_type)
646
+ return responses
647
+
648
+ # Update uptime
649
+ self.state.uptime_ns = self.get_uptime_ns()
650
+
651
+ # Handle acknowledgment (packet type 45, no payload)
652
+ if header.ack_required:
653
+ ack_packet = Device.Acknowledgement()
654
+ ack_header = self._create_response_header(
655
+ header.source,
656
+ header.sequence,
657
+ ack_packet.PKT_TYPE,
658
+ len(ack_packet.pack()),
659
+ )
660
+ responses.append((ack_header, ack_packet))
661
+
662
+ # Handle specific packet types - handlers always return list
663
+ response_packets = self._handle_packet_type(header, packet)
664
+ # Handlers now always return list (empty if no response)
665
+ for resp_packet in response_packets:
666
+ resp_header = self._create_response_header(
667
+ header.source,
668
+ header.sequence,
669
+ resp_packet.PKT_TYPE,
670
+ len(resp_packet.pack()),
671
+ )
672
+ responses.append((resp_header, resp_packet))
673
+
674
+ # Apply error scenarios to responses
675
+ modified_responses = []
676
+ for resp_header, resp_packet in responses:
677
+ # Check if we should send malformed packet (truncate payload)
678
+ if resp_header.pkt_type in scenario.malformed_packets:
679
+ # For malformed packets, we'll pack it first then truncate
680
+ resp_payload = resp_packet.pack() if resp_packet else b""
681
+ truncated_len = len(resp_payload) // 2
682
+ resp_payload = resp_payload[:truncated_len]
683
+ resp_header.size = LIFX_HEADER_SIZE + truncated_len + 10 # Wrong size
684
+ # Convert back to bytes for malformed case
685
+ modified_responses.append((resp_header, resp_payload))
686
+ logger.info(
687
+ "Sending malformed packet type %s (truncated)", resp_header.pkt_type
688
+ )
689
+ continue
690
+
691
+ # Check if we should send invalid field values
692
+ if resp_header.pkt_type in scenario.invalid_field_values:
693
+ # Pack normally then corrupt the bytes
694
+ resp_payload = resp_packet.pack() if resp_packet else b""
695
+ resp_payload = b"\xff" * len(resp_payload)
696
+ modified_responses.append((resp_header, resp_payload))
697
+ pkt_type = resp_header.pkt_type
698
+ logger.info("Sending invalid field values for packet type %s", pkt_type)
699
+ continue
700
+
701
+ modified_responses.append((resp_header, resp_packet))
702
+
703
+ return modified_responses
704
+
705
+ def _handle_packet_type(self, header: LifxHeader, packet: Any | None) -> list[Any]:
706
+ """Handle specific packet types using registered handlers.
707
+
708
+ Returns:
709
+ List of response packets (empty list if no response)
710
+ """
711
+ pkt_type = header.pkt_type
712
+
713
+ # Update uptime for this packet
714
+ self.state.uptime_ns = self.get_uptime_ns()
715
+
716
+ # Find handler for this packet type
717
+ handler = self.handlers.get_handler(pkt_type)
718
+
719
+ if handler:
720
+ # Delegate to handler (always returns list now)
721
+ response = handler.handle(self.state, packet, header.res_required)
722
+
723
+ # Save state if storage is enabled (for SET operations)
724
+ if packet and self.storage:
725
+ self._save_state()
726
+
727
+ return response
728
+ else:
729
+ # Unknown/unimplemented packet type
730
+ from lifx_emulator.protocol.packets import get_packet_class
731
+
732
+ packet_class = get_packet_class(pkt_type)
733
+ if packet_class:
734
+ logger.info(
735
+ "Device %s: Received %s (type %s) but no handler registered",
736
+ self.state.serial,
737
+ packet_class.__qualname__,
738
+ pkt_type,
739
+ )
740
+ else:
741
+ serial = self.state.serial
742
+ logger.warning(
743
+ "Device %s: Received unknown packet type %s", serial, pkt_type
744
+ )
745
+
746
+ # Check scenario for StateUnhandled response
747
+ scenario = self._get_resolved_scenario()
748
+ if scenario.send_unhandled:
749
+ return [Device.StateUnhandled(unhandled_type=pkt_type)]
750
+ return []