lifx-emulator-core 3.1.1__tar.gz → 3.3.0__tar.gz

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 (74) hide show
  1. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/.gitignore +1 -0
  2. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/CHANGELOG.md +26 -0
  3. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/PKG-INFO +1 -1
  4. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/pyproject.toml +1 -1
  5. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/device.py +44 -12
  6. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/multizone_handlers.py +19 -15
  7. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/tile_handlers.py +59 -52
  8. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/models.py +12 -0
  9. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/server.py +55 -4
  10. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_device.py +69 -4
  11. lifx_emulator_core-3.3.0/tests/test_partial_responses.py +301 -0
  12. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_scenario_manager.py +66 -14
  13. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_server.py +125 -0
  14. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_switch_devices.py +12 -14
  15. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/README.md +0 -0
  16. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/__init__.py +0 -0
  17. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/constants.py +0 -0
  18. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/__init__.py +0 -0
  19. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/manager.py +0 -0
  20. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/observers.py +0 -0
  21. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/persistence.py +0 -0
  22. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
  23. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
  24. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/states.py +0 -0
  25. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/__init__.py +0 -0
  26. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/builder.py +0 -0
  27. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/default_config.py +0 -0
  28. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/factory.py +0 -0
  29. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
  30. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
  31. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/__init__.py +0 -0
  32. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/base.py +0 -0
  33. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
  34. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
  35. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/registry.py +0 -0
  36. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/__init__.py +0 -0
  37. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/generator.py +0 -0
  38. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/registry.py +0 -0
  39. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/specs.py +0 -0
  40. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/specs.yml +0 -0
  41. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/__init__.py +0 -0
  42. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/base.py +0 -0
  43. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/const.py +0 -0
  44. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/generator.py +0 -0
  45. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/header.py +0 -0
  46. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/packets.py +0 -0
  47. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
  48. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/serializer.py +0 -0
  49. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/__init__.py +0 -0
  50. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
  51. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
  52. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
  53. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/manager.py +0 -0
  54. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
  55. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/conftest.py +0 -0
  56. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_async_storage.py +0 -0
  57. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_backwards_compatibility.py +0 -0
  58. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_device_edge_cases.py +0 -0
  59. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_device_handlers_extended.py +0 -0
  60. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_device_manager.py +0 -0
  61. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_handler_registry.py +0 -0
  62. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_integration.py +0 -0
  63. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_light_handlers_extended.py +0 -0
  64. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_multizone_handlers_extended.py +0 -0
  65. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_observers.py +0 -0
  66. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_products_generator.py +0 -0
  67. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_products_specs.py +0 -0
  68. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_protocol_generator.py +0 -0
  69. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_protocol_types_coverage.py +0 -0
  70. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_repositories.py +0 -0
  71. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_scenario_persistence.py +0 -0
  72. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_serializer.py +0 -0
  73. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_state_restorer.py +0 -0
  74. {lifx_emulator_core-3.1.1 → lifx_emulator_core-3.3.0}/tests/test_tile_handlers_extended.py +0 -0
@@ -92,3 +92,4 @@ Thumbs.db
92
92
  # Local storage
93
93
  .notes/
94
94
  .claude/settings.local.json
95
+ .envrc
@@ -2,6 +2,32 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v3.3.0 (2026-02-03)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **device**: Apply error scenarios to unhandled-packet responses
10
+ ([`7571ab7`](https://github.com/Djelibeybi/lifx-emulator/commit/7571ab7a9d9833c1b79e0941d0d348c82ebcf34c))
11
+
12
+ ### Features
13
+
14
+ - **server**: Send acks immediately before device processing
15
+ ([`3d4ec66`](https://github.com/Djelibeybi/lifx-emulator/commit/3d4ec66388099d2b672483cf85d47f100fe67549))
16
+
17
+
18
+ ## v3.2.0 (2026-02-02)
19
+
20
+ ### Bug Fixes
21
+
22
+ - **tests**: Replace flaky probabilistic drop rate tests with deterministic mocks
23
+ ([`5070d6e`](https://github.com/Djelibeybi/lifx-emulator/commit/5070d6e88495dfeffaffb8f54f6eb5e6098d0f43))
24
+
25
+ ### Features
26
+
27
+ - **core**: Wire partial_responses scenario through packet handlers
28
+ ([`a799dba`](https://github.com/Djelibeybi/lifx-emulator/commit/a799dba3f2d04501dd5b0694359e3e71e2ed5bbb))
29
+
30
+
5
31
  ## v3.1.1 (2026-02-01)
6
32
 
7
33
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator-core
3
- Version: 3.1.1
3
+ Version: 3.3.0
4
4
  Summary: Core LIFX Emulator library for testing LIFX LAN protocol libraries
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-emulator-core"
3
- version = "3.1.1"
3
+ version = "3.3.0"
4
4
  description = "Core LIFX Emulator library for testing LIFX LAN protocol libraries"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import copy
7
7
  import logging
8
+ import random
8
9
  import time
9
10
  from typing import Any
10
11
 
@@ -269,10 +270,8 @@ class EmulatedLifxDevice:
269
270
  state_unhandled.PKT_TYPE,
270
271
  len(unhandled_payload),
271
272
  )
272
- responses.append((unhandled_header, state_unhandled))
273
-
274
- # Still send acknowledgment if requested
275
- if header.ack_required:
273
+ # Send ack before StateUnhandled when scenario controls ack behavior
274
+ if header.ack_required and scenario.affects_acks:
276
275
  ack_packet = Device.Acknowledgement()
277
276
  ack_payload = ack_packet.pack()
278
277
  ack_header = self._create_response_header(
@@ -281,15 +280,18 @@ class EmulatedLifxDevice:
281
280
  ack_packet.PKT_TYPE,
282
281
  len(ack_payload),
283
282
  )
284
- responses.append((ack_header, ack_packet))
283
+ responses.append((ack_header, ack_packet, ack_payload))
285
284
 
286
- return responses
285
+ responses.append((unhandled_header, state_unhandled, unhandled_payload))
286
+ return self._apply_error_scenarios(responses, scenario)
287
287
 
288
288
  # Update uptime
289
289
  self.state.uptime_ns = self.get_uptime_ns()
290
290
 
291
291
  # Handle acknowledgment (packet type 45, no payload)
292
- if header.ack_required:
292
+ # Only generate ack here when a scenario targets ack behavior;
293
+ # otherwise the server sends the ack immediately before calling us.
294
+ if header.ack_required and scenario.affects_acks:
293
295
  ack_packet = Device.Acknowledgement()
294
296
  ack_payload = ack_packet.pack()
295
297
  ack_header = self._create_response_header(
@@ -304,6 +306,24 @@ class EmulatedLifxDevice:
304
306
 
305
307
  # Handle specific packet types - handlers always return list
306
308
  response_packets = self._handle_packet_type(header, packet)
309
+
310
+ # Apply partial_responses: truncate multi-packet responses to random subset
311
+ if len(response_packets) > 1:
312
+ first_pkt = response_packets[0]
313
+ if (
314
+ hasattr(first_pkt, "PKT_TYPE")
315
+ and first_pkt.PKT_TYPE in scenario.partial_responses
316
+ ):
317
+ original_count = len(response_packets)
318
+ partial_count = random.randint(1, original_count - 1) # nosec
319
+ response_packets = response_packets[:partial_count]
320
+ logger.info(
321
+ "Sending partial response for packet type %s (%d of %d packets)",
322
+ first_pkt.PKT_TYPE,
323
+ partial_count,
324
+ original_count,
325
+ )
326
+
307
327
  # Handlers now always return list (empty if no response)
308
328
  for resp_packet in response_packets:
309
329
  # Cache packed payload to avoid double packing (performance optimization)
@@ -317,16 +337,29 @@ class EmulatedLifxDevice:
317
337
  # Store both header and pre-packed payload for error scenario processing
318
338
  responses.append((resp_header, resp_packet, resp_payload))
319
339
 
320
- # Apply error scenarios to responses
321
- modified_responses = []
340
+ return self._apply_error_scenarios(responses, scenario)
341
+
342
+ def _apply_error_scenarios(
343
+ self,
344
+ responses: list[tuple],
345
+ scenario: ScenarioConfig,
346
+ ) -> list[tuple[LifxHeader, Any]]:
347
+ """Apply malformed/invalid-field error scenarios to response packets.
348
+
349
+ Args:
350
+ responses: List of (header, packet, payload) tuples
351
+ scenario: Resolved scenario config
352
+
353
+ Returns:
354
+ List of (header, packet) tuples with error scenarios applied
355
+ """
356
+ modified_responses: list[tuple[LifxHeader, Any]] = []
322
357
  for resp_header, resp_packet, resp_payload in responses:
323
358
  # Check if we should send malformed packet (truncate payload)
324
359
  if resp_header.pkt_type in scenario.malformed_packets:
325
- # For malformed packets, truncate the pre-packed payload
326
360
  truncated_len = len(resp_payload) // 2
327
361
  resp_payload_modified = resp_payload[:truncated_len]
328
362
  resp_header.size = LIFX_HEADER_SIZE + truncated_len + 10 # Wrong size
329
- # Convert back to bytes for malformed case
330
363
  modified_responses.append((resp_header, resp_payload_modified))
331
364
  logger.info(
332
365
  "Sending malformed packet type %s (truncated)", resp_header.pkt_type
@@ -335,7 +368,6 @@ class EmulatedLifxDevice:
335
368
 
336
369
  # Check if we should send invalid field values
337
370
  if resp_header.pkt_type in scenario.invalid_field_values:
338
- # Corrupt the pre-packed payload
339
371
  resp_payload_modified = b"\xff" * len(resp_payload)
340
372
  modified_responses.append((resp_header, resp_payload_modified))
341
373
  pkt_type = resp_header.pkt_type
@@ -124,22 +124,26 @@ class ExtendedGetColorZonesHandler(PacketHandler):
124
124
  if not device_state.has_multizone:
125
125
  return []
126
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,
127
+ responses = []
128
+ index = 0
129
+ while index < device_state.zone_count:
130
+ end = min(index + 82, device_state.zone_count)
131
+ colors_count = end - index
132
+ colors = list(device_state.zone_colors[index:end])
133
+ # Pad to 82 colors
134
+ while len(colors) < 82:
135
+ colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
136
+ responses.append(
137
+ MultiZone.ExtendedStateMultiZone(
138
+ count=device_state.zone_count,
139
+ index=index,
140
+ colors_count=colors_count,
141
+ colors=colors,
142
+ )
141
143
  )
142
- ]
144
+ index += 82
145
+
146
+ return responses
143
147
 
144
148
 
145
149
  class ExtendedSetColorZonesHandler(PacketHandler):
@@ -128,64 +128,71 @@ class Get64Handler(PacketHandler):
128
128
  if not device_state.has_matrix or not packet:
129
129
  return []
130
130
 
131
- tile_index = packet.tile_index
132
131
  rect = packet.rect
132
+ length = max(1, packet.length)
133
133
 
134
- if tile_index >= len(device_state.tile_devices):
135
- return []
136
-
137
- tile = device_state.tile_devices[tile_index]
138
- tile_width = tile["width"]
139
- tile_height = tile["height"]
140
-
141
- # Get64 always returns framebuffer 0 (the visible buffer)
142
- # regardless of which fb_index is in the request
143
- tile_colors = tile["colors"]
144
-
145
- # Calculate how many rows fit in 64 zones
146
- rows_to_return = 64 // rect.width if rect.width > 0 else 1
147
- rows_to_return = min(rows_to_return, tile_height - rect.y)
148
-
149
- # Extract colors from the requested rectangle
150
- colors = []
151
- zones_extracted = 0
152
-
153
- for row in range(rows_to_return):
154
- y = rect.y + row
155
- if y >= tile_height:
134
+ responses = []
135
+ for i in range(length):
136
+ idx = packet.tile_index + i
137
+ if idx >= len(device_state.tile_devices):
156
138
  break
157
139
 
158
- for col in range(rect.width):
159
- x = rect.x + col
160
- if x >= tile_width or zones_extracted >= 64:
161
- colors.append(
162
- LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
163
- )
140
+ tile = device_state.tile_devices[idx]
141
+ tile_width = tile["width"]
142
+ tile_height = tile["height"]
143
+
144
+ # Get64 always returns framebuffer 0 (the visible buffer)
145
+ # regardless of which fb_index is in the request
146
+ tile_colors = tile["colors"]
147
+
148
+ # Calculate how many rows fit in 64 zones
149
+ rows_to_return = 64 // rect.width if rect.width > 0 else 1
150
+ rows_to_return = min(rows_to_return, tile_height - rect.y)
151
+
152
+ # Extract colors from the requested rectangle
153
+ colors = []
154
+ zones_extracted = 0
155
+
156
+ for row in range(rows_to_return):
157
+ y = rect.y + row
158
+ if y >= tile_height:
159
+ break
160
+
161
+ for col in range(rect.width):
162
+ x = rect.x + col
163
+ if x >= tile_width or zones_extracted >= 64:
164
+ colors.append(
165
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
166
+ )
167
+ zones_extracted += 1
168
+ continue
169
+
170
+ # Calculate zone index in flat color array
171
+ zone_idx = y * tile_width + x
172
+ if zone_idx < len(tile_colors):
173
+ colors.append(tile_colors[zone_idx])
174
+ else:
175
+ colors.append(
176
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
177
+ )
164
178
  zones_extracted += 1
165
- continue
166
179
 
167
- # Calculate zone index in flat color array
168
- zone_idx = y * tile_width + x
169
- if zone_idx < len(tile_colors):
170
- colors.append(tile_colors[zone_idx])
171
- else:
172
- colors.append(
173
- LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
174
- )
175
- zones_extracted += 1
176
-
177
- # Pad to exactly 64 colors
178
- while len(colors) < 64:
179
- colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
180
-
181
- # Return with fb_index forced to 0 (visible buffer)
182
- return_rect = TileBufferRect(
183
- fb_index=0, # Always return FB0
184
- x=rect.x,
185
- y=rect.y,
186
- width=rect.width,
187
- )
188
- return [Tile.State64(tile_index=tile_index, rect=return_rect, colors=colors)]
180
+ # Pad to exactly 64 colors
181
+ while len(colors) < 64:
182
+ colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
183
+
184
+ # Return with fb_index forced to 0 (visible buffer)
185
+ return_rect = TileBufferRect(
186
+ fb_index=0, # Always return FB0
187
+ x=rect.x,
188
+ y=rect.y,
189
+ width=rect.width,
190
+ )
191
+ responses.append(
192
+ Tile.State64(tile_index=idx, rect=return_rect, colors=colors)
193
+ )
194
+
195
+ return responses
189
196
 
190
197
 
191
198
  class Set64Handler(PacketHandler):
@@ -6,6 +6,8 @@ of the application (domain, API, persistence, etc.).
6
6
 
7
7
  from pydantic import BaseModel, Field, field_validator
8
8
 
9
+ ACK_PACKET_TYPE = 45
10
+
9
11
 
10
12
  class ScenarioConfig(BaseModel):
11
13
  """Scenario configuration for testing LIFX protocol behavior.
@@ -52,6 +54,16 @@ class ScenarioConfig(BaseModel):
52
54
  False, description="Send unhandled message responses for unknown packet types"
53
55
  )
54
56
 
57
+ @property
58
+ def affects_acks(self) -> bool:
59
+ """Whether this scenario configuration modifies acknowledgment behavior."""
60
+ return (
61
+ ACK_PACKET_TYPE in self.drop_packets
62
+ or ACK_PACKET_TYPE in self.response_delays
63
+ or ACK_PACKET_TYPE in self.malformed_packets
64
+ or ACK_PACKET_TYPE in self.invalid_field_values
65
+ )
66
+
55
67
  @field_validator("drop_packets", mode="before")
56
68
  @classmethod
57
69
  def convert_drop_packets_keys(cls, v):
@@ -18,7 +18,7 @@ from lifx_emulator.devices import (
18
18
  PacketEvent,
19
19
  )
20
20
  from lifx_emulator.protocol.header import LifxHeader
21
- from lifx_emulator.protocol.packets import get_packet_class
21
+ from lifx_emulator.protocol.packets import Device, get_packet_class
22
22
  from lifx_emulator.repositories import IScenarioStorageBackend
23
23
  from lifx_emulator.scenarios import HierarchicalScenarioManager
24
24
 
@@ -179,6 +179,54 @@ class EmulatedLifxServer:
179
179
  # Fallback for edge case where loop not yet cached
180
180
  asyncio.create_task(self.server.handle_packet(data, addr))
181
181
 
182
+ def _send_ack(
183
+ self,
184
+ device: EmulatedLifxDevice,
185
+ header: LifxHeader,
186
+ addr: tuple[str, int],
187
+ ) -> None:
188
+ """Send an acknowledgment packet immediately via UDP.
189
+
190
+ Args:
191
+ device: The device acknowledging the packet
192
+ header: Request header (source/sequence are copied)
193
+ addr: Client address (host, port)
194
+ """
195
+ ack_packet = Device.Acknowledgement()
196
+ ack_payload = ack_packet.pack()
197
+ ack_header = device._create_response_header(
198
+ header.source,
199
+ header.sequence,
200
+ ack_packet.PKT_TYPE,
201
+ len(ack_payload),
202
+ )
203
+ response_data = ack_header.pack() + ack_payload
204
+ if self.transport:
205
+ self.transport.sendto(response_data, addr)
206
+
207
+ self.packets_sent += 1
208
+ self.packets_sent_by_type[ack_header.pkt_type] += 1
209
+
210
+ logger.debug(
211
+ "→ TX %s to %s:%s (target=%s, seq=%s) [no fields]",
212
+ _get_packet_type_name(ack_header.pkt_type),
213
+ addr[0],
214
+ addr[1],
215
+ device.state.serial,
216
+ ack_header.sequence,
217
+ )
218
+
219
+ self.activity_observer.on_packet_sent(
220
+ PacketEvent(
221
+ timestamp=time.time(),
222
+ direction="tx",
223
+ packet_type=ack_header.pkt_type,
224
+ packet_name=_get_packet_type_name(ack_header.pkt_type),
225
+ addr=f"{addr[0]}:{addr[1]}",
226
+ device=device.state.serial,
227
+ )
228
+ )
229
+
182
230
  async def _process_device_packet(
183
231
  self,
184
232
  device: EmulatedLifxDevice,
@@ -194,10 +242,13 @@ class EmulatedLifxServer:
194
242
  packet: Parsed packet payload (or None)
195
243
  addr: Client address (host, port)
196
244
  """
197
- responses = device.process_packet(header, packet)
198
-
199
- # Get resolved scenario for response delays
245
+ # Send ack immediately before device processing when no scenario
246
+ # targets ack behavior (fast path for the common case)
200
247
  scenario = device._get_resolved_scenario()
248
+ if header.ack_required and not scenario.affects_acks:
249
+ self._send_ack(device, header, addr)
250
+
251
+ responses = device.process_packet(header, packet)
201
252
 
202
253
  # Send responses with delay if configured
203
254
  for resp_header, resp_packet in responses:
@@ -503,8 +503,12 @@ class TestTileHandlers:
503
503
  class TestAcknowledgment:
504
504
  """Test acknowledgment packet generation."""
505
505
 
506
- def test_ack_required_generates_acknowledgment(self, color_device):
507
- """Test ack_required flag generates Acknowledgement packet."""
506
+ def test_ack_not_generated_without_scenario(self, color_device):
507
+ """Test process_packet does not include ack when no scenario targets acks.
508
+
509
+ The server sends acks immediately before calling process_packet,
510
+ so the device should not include one in its response list.
511
+ """
508
512
  header = LifxHeader(
509
513
  source=12345,
510
514
  target=color_device.state.get_target_bytes(),
@@ -516,14 +520,75 @@ class TestAcknowledgment:
516
520
 
517
521
  responses = color_device.process_packet(header, None)
518
522
 
519
- # Should have at least 2 responses: ACK + StatePower
523
+ # Should only have StatePower, no ACK (server handles ack)
524
+ assert len(responses) == 1
525
+ resp_header, resp_packet = responses[0]
526
+ assert resp_header.pkt_type == 22 # StatePower
527
+
528
+ def test_ack_generated_when_scenario_affects_acks(self, color_device):
529
+ """Test process_packet includes ack when scenario targets ack behavior."""
530
+ from lifx_emulator.scenarios import HierarchicalScenarioManager, ScenarioConfig
531
+
532
+ scenario_manager = HierarchicalScenarioManager()
533
+ scenario_manager.set_device_scenario(
534
+ color_device.state.serial,
535
+ ScenarioConfig(response_delays={45: 1.0}),
536
+ )
537
+ color_device.scenario_manager = scenario_manager
538
+ color_device.invalidate_scenario_cache()
539
+
540
+ header = LifxHeader(
541
+ source=12345,
542
+ target=color_device.state.get_target_bytes(),
543
+ sequence=1,
544
+ pkt_type=20, # GetPower
545
+ ack_required=True,
546
+ res_required=True,
547
+ )
548
+
549
+ responses = color_device.process_packet(header, None)
550
+
551
+ # Should have ACK + StatePower
520
552
  assert len(responses) >= 2
553
+ ack_header, ack_packet = responses[0]
554
+ assert ack_header.pkt_type == 45
555
+ assert isinstance(ack_packet, Device.Acknowledgement)
556
+
557
+ def test_ack_generated_for_unhandled_packet_when_scenario_affects_acks(
558
+ self, color_device
559
+ ):
560
+ """Test ACK included for unsupported packets when scenario targets acks."""
561
+ from lifx_emulator.scenarios import HierarchicalScenarioManager, ScenarioConfig
562
+
563
+ scenario_manager = HierarchicalScenarioManager()
564
+ scenario_manager.set_device_scenario(
565
+ color_device.state.serial,
566
+ ScenarioConfig(response_delays={45: 1.0}),
567
+ )
568
+ color_device.scenario_manager = scenario_manager
569
+ color_device.invalidate_scenario_cache()
570
+
571
+ header = LifxHeader(
572
+ source=12345,
573
+ target=color_device.state.get_target_bytes(),
574
+ sequence=1,
575
+ pkt_type=501, # MultiZone.GetColorZones — unsupported on color device
576
+ ack_required=True,
577
+ res_required=True,
578
+ )
579
+
580
+ responses = color_device.process_packet(header, None)
521
581
 
522
- # First response should be ACK
582
+ # Should have ACK followed by StateUnhandled
583
+ assert len(responses) == 2
523
584
  ack_header, ack_packet = responses[0]
524
585
  assert ack_header.pkt_type == 45
525
586
  assert isinstance(ack_packet, Device.Acknowledgement)
526
587
 
588
+ unhandled_header, unhandled_packet = responses[1]
589
+ assert unhandled_header.pkt_type == 223 # StateUnhandled
590
+ assert unhandled_packet.unhandled_type == 501
591
+
527
592
  def test_no_ack_when_not_required(self, color_device):
528
593
  """Test no ACK when ack_required is False."""
529
594
  header = LifxHeader(