lifx-emulator-core 3.2.0__py3-none-any.whl → 3.3.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.
@@ -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):
lifx_emulator/server.py CHANGED
@@ -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:
@@ -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,8 +1,8 @@
1
1
  lifx_emulator/__init__.py,sha256=SSnQg0RiCaID7DhHOGZoVIDQ3_8Lyt0J9cA_2StF63s,824
2
2
  lifx_emulator/constants.py,sha256=DFZkUsdewE-x_3MgO28tMGkjUCWPeYc3xLj_EXViGOw,1032
3
- lifx_emulator/server.py,sha256=Fc0AiSeJ43XnF1MMTqWBkeYIW-KOhZpqNp0Y2x4RqEE,16622
3
+ lifx_emulator/server.py,sha256=x-RDfxwmzUfSSROfBzVm2LcGK68sVIM4o7IkcgzkSfI,18363
4
4
  lifx_emulator/devices/__init__.py,sha256=QlBTPnFErJcSKLvGyeDwemh7xcpjYvB_L5siKsjr3s8,1089
5
- lifx_emulator/devices/device.py,sha256=99uxNdK3vFnBHh9M2F92zxNeFkq3w-XXV0Mt8wRTUQw,16556
5
+ lifx_emulator/devices/device.py,sha256=w7Y9VYux3cyGus9mFmruFn6WYtoD7zwhzCL2xrICY-Q,17180
6
6
  lifx_emulator/devices/manager.py,sha256=XDrT82um5sgNpNihLj5RsNvHqdVI1bK9YY2eBzWIcf0,8162
7
7
  lifx_emulator/devices/observers.py,sha256=-KnUgFcKdhlNo7CNVstP-u0wU2W0JAGg055ZPV15Sj0,3874
8
8
  lifx_emulator/devices/persistence.py,sha256=9Mhj46-xrweOmyzjORCi2jKIwa8XJWpQ5CgaKcw6U98,10513
@@ -40,8 +40,8 @@ lifx_emulator/repositories/device_repository.py,sha256=KsXVg2sg7PGSTsK_PvDYeHHwE
40
40
  lifx_emulator/repositories/storage_backend.py,sha256=wEgjhnBvAxl6aO1ZGL3ou0dW9P2hBPnK8jEE03sOlL4,3264
41
41
  lifx_emulator/scenarios/__init__.py,sha256=CGjudoWvyysvFj2xej11N2cr3mYROGtRb9zVHcOHGrQ,665
42
42
  lifx_emulator/scenarios/manager.py,sha256=1esxRdz74UynNk1wb86MGZ2ZFAuMzByuu74nRe3D-Og,11163
43
- lifx_emulator/scenarios/models.py,sha256=BKS_fGvrbkGe-vK3arZ0w2f9adS1UZhiOoKpu7GENnc,4099
43
+ lifx_emulator/scenarios/models.py,sha256=1cX399JcTYVo29-8Rc4BwYPRty7sMR4fcn0njGfspZg,4504
44
44
  lifx_emulator/scenarios/persistence.py,sha256=3vjtPNFYfag38tUxuqxkGpWhQ7uBitc1rLroSAuw9N8,8881
45
- lifx_emulator_core-3.2.0.dist-info/METADATA,sha256=qxUBmkizy2Eu0QNVQ8y6FNppwJaqbR5pEIDnZzTasu8,3217
46
- lifx_emulator_core-3.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
- lifx_emulator_core-3.2.0.dist-info/RECORD,,
45
+ lifx_emulator_core-3.3.0.dist-info/METADATA,sha256=JtFPPtM9n0gsUawt_SHBvR_d67384-wheXW8viWc5Vc,3217
46
+ lifx_emulator_core-3.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
+ lifx_emulator_core-3.3.0.dist-info/RECORD,,