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.
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/.gitignore +1 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/CHANGELOG.md +13 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/PKG-INFO +1 -1
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/pyproject.toml +1 -1
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/device.py +25 -12
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/models.py +12 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/server.py +55 -4
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device.py +69 -4
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_scenario_manager.py +39 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_server.py +125 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_switch_devices.py +12 -14
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/README.md +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/constants.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/manager.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/observers.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/persistence.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/states.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/builder.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/default_config.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/factory.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/base.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/multizone_handlers.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/registry.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/tile_handlers.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/generator.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/registry.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/specs.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/specs.yml +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/base.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/const.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/generator.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/header.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/packets.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/serializer.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/manager.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/conftest.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_async_storage.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_backwards_compatibility.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device_edge_cases.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device_handlers_extended.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device_manager.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_handler_registry.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_integration.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_light_handlers_extended.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_multizone_handlers_extended.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_observers.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_partial_responses.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_products_generator.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_products_specs.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_protocol_generator.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_protocol_types_coverage.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_repositories.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_scenario_persistence.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_serializer.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_state_restorer.py +0 -0
- {lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_tile_handlers_extended.py +0 -0
|
@@ -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
|
|
@@ -270,10 +270,8 @@ class EmulatedLifxDevice:
|
|
|
270
270
|
state_unhandled.PKT_TYPE,
|
|
271
271
|
len(unhandled_payload),
|
|
272
272
|
)
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
|
507
|
-
"""Test
|
|
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
|
|
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
|
-
#
|
|
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
|
|
171
|
-
"""Test switch returns
|
|
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
|
|
188
|
-
assert len(responses) ==
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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=
|
|
322
|
+
ack_required=False,
|
|
325
323
|
res_required=True,
|
|
326
324
|
)
|
|
327
325
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/observers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/persistence.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_restorer.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/devices/state_serializer.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/builder.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/default_config.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/factory.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/factories/firmware_config.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/device_handlers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/light_handlers.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/registry.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/handlers/tile_handlers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/generator.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/products/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/generator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/protocol_types.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/protocol/serializer.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/repositories/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/manager.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/src/lifx_emulator/scenarios/persistence.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_device_handlers_extended.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.2.0 → lifx_emulator_core-3.3.0}/tests/test_multizone_handlers_extended.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|