lifx-emulator-core 3.2.0__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.2.0 → lifx_emulator_core-3.3.0}/.gitignore +1 -0
  2. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/CHANGELOG.md +13 -0
  3. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/PKG-INFO +1 -1
  4. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/pyproject.toml +1 -1
  5. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/device.py +25 -12
  6. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/models.py +12 -0
  7. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/server.py +55 -4
  8. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device.py +69 -4
  9. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_scenario_manager.py +39 -0
  10. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_server.py +125 -0
  11. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_switch_devices.py +12 -14
  12. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/README.md +0 -0
  13. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/__init__.py +0 -0
  14. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/constants.py +0 -0
  15. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/__init__.py +0 -0
  16. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/manager.py +0 -0
  17. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/observers.py +0 -0
  18. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/persistence.py +0 -0
  19. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
  20. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
  21. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/states.py +0 -0
  22. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/__init__.py +0 -0
  23. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/builder.py +0 -0
  24. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/default_config.py +0 -0
  25. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/factory.py +0 -0
  26. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
  27. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
  28. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/__init__.py +0 -0
  29. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/base.py +0 -0
  30. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
  31. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
  32. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/multizone_handlers.py +0 -0
  33. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/registry.py +0 -0
  34. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/tile_handlers.py +0 -0
  35. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/__init__.py +0 -0
  36. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/generator.py +0 -0
  37. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/registry.py +0 -0
  38. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/specs.py +0 -0
  39. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/specs.yml +0 -0
  40. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/__init__.py +0 -0
  41. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/base.py +0 -0
  42. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/const.py +0 -0
  43. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/generator.py +0 -0
  44. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/header.py +0 -0
  45. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/packets.py +0 -0
  46. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
  47. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/serializer.py +0 -0
  48. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/__init__.py +0 -0
  49. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
  50. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
  51. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
  52. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/manager.py +0 -0
  53. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
  54. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/conftest.py +0 -0
  55. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_async_storage.py +0 -0
  56. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_backwards_compatibility.py +0 -0
  57. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device_edge_cases.py +0 -0
  58. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device_handlers_extended.py +0 -0
  59. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device_manager.py +0 -0
  60. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_handler_registry.py +0 -0
  61. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_integration.py +0 -0
  62. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_light_handlers_extended.py +0 -0
  63. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_multizone_handlers_extended.py +0 -0
  64. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_observers.py +0 -0
  65. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_partial_responses.py +0 -0
  66. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_products_generator.py +0 -0
  67. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_products_specs.py +0 -0
  68. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_protocol_generator.py +0 -0
  69. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_protocol_types_coverage.py +0 -0
  70. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_repositories.py +0 -0
  71. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_scenario_persistence.py +0 -0
  72. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_serializer.py +0 -0
  73. {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_state_restorer.py +0 -0
  74. {lifx_emulator_core-3.2.0 → 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,19 @@
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
+
5
18
  ## v3.2.0 (2026-02-02)
6
19
 
7
20
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator-core
3
- Version: 3.2.0
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.2.0"
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"
@@ -270,10 +270,8 @@ class EmulatedLifxDevice:
270
270
  state_unhandled.PKT_TYPE,
271
271
  len(unhandled_payload),
272
272
  )
273
- responses.append((unhandled_header, state_unhandled))
274
-
275
- # Still send acknowledgment if requested
276
- if header.ack_required:
273
+ # Send ack before StateUnhandled when scenario controls ack behavior
274
+ if header.ack_required and scenario.affects_acks:
277
275
  ack_packet = Device.Acknowledgement()
278
276
  ack_payload = ack_packet.pack()
279
277
  ack_header = self._create_response_header(
@@ -282,15 +280,18 @@ class EmulatedLifxDevice:
282
280
  ack_packet.PKT_TYPE,
283
281
  len(ack_payload),
284
282
  )
285
- responses.append((ack_header, ack_packet))
283
+ responses.append((ack_header, ack_packet, ack_payload))
286
284
 
287
- return responses
285
+ responses.append((unhandled_header, state_unhandled, unhandled_payload))
286
+ return self._apply_error_scenarios(responses, scenario)
288
287
 
289
288
  # Update uptime
290
289
  self.state.uptime_ns = self.get_uptime_ns()
291
290
 
292
291
  # Handle acknowledgment (packet type 45, no payload)
293
- 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:
294
295
  ack_packet = Device.Acknowledgement()
295
296
  ack_payload = ack_packet.pack()
296
297
  ack_header = self._create_response_header(
@@ -336,16 +337,29 @@ class EmulatedLifxDevice:
336
337
  # Store both header and pre-packed payload for error scenario processing
337
338
  responses.append((resp_header, resp_packet, resp_payload))
338
339
 
339
- # Apply error scenarios to responses
340
- 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]] = []
341
357
  for resp_header, resp_packet, resp_payload in responses:
342
358
  # Check if we should send malformed packet (truncate payload)
343
359
  if resp_header.pkt_type in scenario.malformed_packets:
344
- # For malformed packets, truncate the pre-packed payload
345
360
  truncated_len = len(resp_payload) // 2
346
361
  resp_payload_modified = resp_payload[:truncated_len]
347
362
  resp_header.size = LIFX_HEADER_SIZE + truncated_len + 10 # Wrong size
348
- # Convert back to bytes for malformed case
349
363
  modified_responses.append((resp_header, resp_payload_modified))
350
364
  logger.info(
351
365
  "Sending malformed packet type %s (truncated)", resp_header.pkt_type
@@ -354,7 +368,6 @@ class EmulatedLifxDevice:
354
368
 
355
369
  # Check if we should send invalid field values
356
370
  if resp_header.pkt_type in scenario.invalid_field_values:
357
- # Corrupt the pre-packed payload
358
371
  resp_payload_modified = b"\xff" * len(resp_payload)
359
372
  modified_responses.append((resp_header, resp_payload_modified))
360
373
  pkt_type = resp_header.pkt_type
@@ -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(
@@ -442,3 +442,42 @@ class TestDeviceIntegration:
442
442
 
443
443
  assert 101 in scenario.drop_packets
444
444
  assert 102 in scenario.drop_packets
445
+
446
+
447
+ class TestAffectsAcks:
448
+ """Test ScenarioConfig.affects_acks property."""
449
+
450
+ def test_default_does_not_affect_acks(self):
451
+ """Test default ScenarioConfig does not affect acks."""
452
+ config = ScenarioConfig()
453
+ assert config.affects_acks is False
454
+
455
+ def test_drop_packets_with_ack_type(self):
456
+ """Test affects_acks when ack type 45 is in drop_packets."""
457
+ config = ScenarioConfig(drop_packets={45: 1.0})
458
+ assert config.affects_acks is True
459
+
460
+ def test_response_delays_with_ack_type(self):
461
+ """Test affects_acks when ack type 45 is in response_delays."""
462
+ config = ScenarioConfig(response_delays={45: 0.5})
463
+ assert config.affects_acks is True
464
+
465
+ def test_malformed_packets_with_ack_type(self):
466
+ """Test affects_acks when ack type 45 is in malformed_packets."""
467
+ config = ScenarioConfig(malformed_packets=[45])
468
+ assert config.affects_acks is True
469
+
470
+ def test_invalid_field_values_with_ack_type(self):
471
+ """Test affects_acks when ack type 45 is in invalid_field_values."""
472
+ config = ScenarioConfig(invalid_field_values=[45])
473
+ assert config.affects_acks is True
474
+
475
+ def test_other_packet_types_do_not_affect_acks(self):
476
+ """Test affects_acks is False when other types are configured but not 45."""
477
+ config = ScenarioConfig(
478
+ drop_packets={101: 1.0},
479
+ response_delays={107: 0.5},
480
+ malformed_packets=[102],
481
+ invalid_field_values=[103],
482
+ )
483
+ assert config.affects_acks is False
@@ -568,3 +568,128 @@ class TestSequenceHandling:
568
568
  sent_data, _ = server.transport.sendto.call_args[0]
569
569
  resp_header = LifxHeader.unpack(sent_data)
570
570
  assert resp_header.source == 99999
571
+
572
+
573
+ class TestServerAckBehavior:
574
+ """Test server sends acks immediately before device processing."""
575
+
576
+ @pytest.mark.asyncio
577
+ async def test_ack_sent_first_before_handler_response(self, color_device):
578
+ """Test ack is the first sendto call when ack_required=True."""
579
+ from lifx_emulator.constants import HEADER_SIZE
580
+ from lifx_emulator.protocol.packets import Light
581
+ from lifx_emulator.protocol.protocol_types import LightHsbk
582
+
583
+ device_manager = DeviceManager(DeviceRepository())
584
+ server = EmulatedLifxServer([color_device], device_manager, "127.0.0.1", 56700)
585
+
586
+ color = LightHsbk(hue=10000, saturation=65535, brightness=50000, kelvin=3500)
587
+ set_color_packet = Light.SetColor(color=color, duration=0)
588
+ payload = set_color_packet.pack()
589
+
590
+ header = LifxHeader(
591
+ size=HEADER_SIZE + len(payload),
592
+ source=12345,
593
+ target=color_device.state.get_target_bytes(),
594
+ sequence=1,
595
+ pkt_type=102, # SetColor
596
+ ack_required=True,
597
+ res_required=True,
598
+ )
599
+
600
+ packet_data = header.pack() + payload
601
+ addr = ("127.0.0.1", 56700)
602
+
603
+ server.transport = Mock()
604
+ server.transport.sendto = Mock()
605
+
606
+ await server.handle_packet(packet_data, addr)
607
+
608
+ # First sendto call should be the ack (type 45)
609
+ assert server.transport.sendto.call_count >= 2
610
+ first_call_data = server.transport.sendto.call_args_list[0][0][0]
611
+ first_resp_header = LifxHeader.unpack(first_call_data)
612
+ assert first_resp_header.pkt_type == 45 # Acknowledgement
613
+
614
+ # Second call should be the handler response (StateColor = 107)
615
+ second_call_data = server.transport.sendto.call_args_list[1][0][0]
616
+ second_resp_header = LifxHeader.unpack(second_call_data)
617
+ assert second_resp_header.pkt_type == 107 # StateColor
618
+
619
+ @pytest.mark.asyncio
620
+ async def test_server_does_not_send_ack_when_scenario_affects_acks(
621
+ self, color_device
622
+ ):
623
+ """Test server skips ack when scenario targets ack behavior."""
624
+ from lifx_emulator.scenarios.manager import (
625
+ HierarchicalScenarioManager,
626
+ ScenarioConfig,
627
+ )
628
+
629
+ scenario_manager = HierarchicalScenarioManager()
630
+ scenario_manager.set_device_scenario(
631
+ color_device.state.serial,
632
+ ScenarioConfig(response_delays={45: 0.0}), # Targets ack type
633
+ )
634
+
635
+ device_manager = DeviceManager(DeviceRepository())
636
+ server = EmulatedLifxServer(
637
+ [color_device],
638
+ device_manager,
639
+ "127.0.0.1",
640
+ 56700,
641
+ scenario_manager=scenario_manager,
642
+ )
643
+
644
+ header = LifxHeader(
645
+ source=12345,
646
+ target=color_device.state.get_target_bytes(),
647
+ sequence=1,
648
+ pkt_type=20, # GetPower
649
+ ack_required=True,
650
+ res_required=True,
651
+ )
652
+
653
+ packet_data = header.pack()
654
+ addr = ("127.0.0.1", 56700)
655
+
656
+ server.transport = Mock()
657
+ server.transport.sendto = Mock()
658
+
659
+ await server.handle_packet(packet_data, addr)
660
+
661
+ # All responses should come from device.process_packet()
662
+ # The first should be the ack (device handles it when scenario targets acks)
663
+ assert server.transport.sendto.call_count >= 2
664
+ first_call_data = server.transport.sendto.call_args_list[0][0][0]
665
+ first_resp_header = LifxHeader.unpack(first_call_data)
666
+ assert first_resp_header.pkt_type == 45 # Ack from device
667
+
668
+ @pytest.mark.asyncio
669
+ async def test_no_ack_when_not_required(self, color_device):
670
+ """Test no ack is sent when ack_required=False."""
671
+ device_manager = DeviceManager(DeviceRepository())
672
+ server = EmulatedLifxServer([color_device], device_manager, "127.0.0.1", 56700)
673
+
674
+ header = LifxHeader(
675
+ source=12345,
676
+ target=color_device.state.get_target_bytes(),
677
+ sequence=1,
678
+ pkt_type=23, # GetLabel
679
+ ack_required=False,
680
+ res_required=True,
681
+ )
682
+
683
+ packet_data = header.pack()
684
+ addr = ("127.0.0.1", 56700)
685
+
686
+ server.transport = Mock()
687
+ server.transport.sendto = Mock()
688
+
689
+ await server.handle_packet(packet_data, addr)
690
+
691
+ # Should have exactly 1 response (StateLabel), no ack
692
+ assert server.transport.sendto.call_count == 1
693
+ sent_data = server.transport.sendto.call_args_list[0][0][0]
694
+ resp_header = LifxHeader.unpack(sent_data)
695
+ assert resp_header.pkt_type == 25 # StateLabel
@@ -167,8 +167,12 @@ class TestSwitchStateUnhandled:
167
167
  assert resp_packet.PKT_TYPE == 223 # StateUnhandled
168
168
  assert resp_packet.unhandled_type == 102 # SetColor was rejected
169
169
 
170
- def test_switch_returns_state_unhandled_with_ack(self):
171
- """Test switch returns both StateUnhandled and Acknowledgement."""
170
+ def test_switch_returns_state_unhandled_without_ack(self):
171
+ """Test switch returns only StateUnhandled when no scenario targets acks.
172
+
173
+ The server sends acks before calling process_packet, so the device
174
+ should not include one in its response list by default.
175
+ """
172
176
  switch = create_switch("d073d7000001")
173
177
 
174
178
  header = LifxHeader(
@@ -184,17 +188,11 @@ class TestSwitchStateUnhandled:
184
188
 
185
189
  responses = switch.process_packet(header, None)
186
190
 
187
- # Should get both StateUnhandled and Acknowledgement
188
- assert len(responses) == 2
189
-
190
- # First response: StateUnhandled
191
- resp_header1, resp_packet1 = responses[0]
192
- assert resp_packet1.PKT_TYPE == 223 # StateUnhandled
193
- assert resp_packet1.unhandled_type == 101
194
-
195
- # Second response: Acknowledgement
196
- resp_header2, resp_packet2 = responses[1]
197
- assert resp_packet2.PKT_TYPE == 45 # Acknowledgement
191
+ # Should get only StateUnhandled (server handles ack)
192
+ assert len(responses) == 1
193
+ resp_header, resp_packet = responses[0]
194
+ assert resp_packet.PKT_TYPE == 223 # StateUnhandled
195
+ assert resp_packet.unhandled_type == 101
198
196
 
199
197
  def test_switch_handles_device_packets_normally(self):
200
198
  """Test switch handles Device.* packets without StateUnhandled."""
@@ -321,7 +319,7 @@ class TestSwitchEdgeCases:
321
319
  tagged=False,
322
320
  pkt_type=101, # Light.GetColor
323
321
  size=36,
324
- ack_required=True,
322
+ ack_required=False,
325
323
  res_required=True,
326
324
  )
327
325