lifx-emulator-core 3.1.1__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.
@@ -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):
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.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,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=yEOXc_xr1X45bJzG2qB-A-oIHwnA8qqYlIsFialobGc,15780
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
@@ -19,9 +19,9 @@ lifx_emulator/handlers/__init__.py,sha256=3Hj1hRo3yL3E7GKwG9TaYh33ymk_N3bRiQ8nvq
19
19
  lifx_emulator/handlers/base.py,sha256=0avCLXY_rNlw16PpJ5JrRCwXNE4uMpBqF3PfSfNJ0b8,1654
20
20
  lifx_emulator/handlers/device_handlers.py,sha256=1AmslA4Ut6L7b3SfduDdvnQizTpzUB3KKWBXmp4WYLQ,9462
21
21
  lifx_emulator/handlers/light_handlers.py,sha256=255aoiIjSIL63kbHQa6wqUpEwFzFFx7SG6P1nWM9jgU,17769
22
- lifx_emulator/handlers/multizone_handlers.py,sha256=2dYsitq0KzEaxEAJmz7ixtir1tvFMOAnfkBQqslqbPM,7914
22
+ lifx_emulator/handlers/multizone_handlers.py,sha256=ypv9G7od2bdQc7plRb38hyPyd02ugiMsinUHKCB2cdM,8094
23
23
  lifx_emulator/handlers/registry.py,sha256=s1ht4PmPhXhAcwu1hoY4yW39wy3SPJBMY-9Uxd0FWuE,3292
24
- lifx_emulator/handlers/tile_handlers.py,sha256=L4fNKGTSSIxRuqKrfDrMSrNPvDJr3aIuaEqbhRCOt04,17176
24
+ lifx_emulator/handlers/tile_handlers.py,sha256=TRrXfq1L-1WR35dhYMv3_GE-8pDX9yrYDHZUGbBNlSE,17495
25
25
  lifx_emulator/products/__init__.py,sha256=qcNop_kRYFF3zSjNemzQEgu3jPrIxfyQyLv9GsnaLEI,627
26
26
  lifx_emulator/products/generator.py,sha256=fvrhw_b7shLCtEtUFxWF5VBEQAeSrsaiXxoGIP5Vn4g,34675
27
27
  lifx_emulator/products/registry.py,sha256=1SZ3fXVFFL8jhKYIZBqwtIQDN3qL1Lvf86P3N1_Kdx8,47323
@@ -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.1.1.dist-info/METADATA,sha256=R36u6hulOrRLCHQH9Zod87RRgmiupFoFEyEGlQE9WzE,3217
46
- lifx_emulator_core-3.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
- lifx_emulator_core-3.1.1.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,,