lifx-emulator-core 3.1.0__tar.gz → 3.2.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.1.0 → lifx_emulator_core-3.2.0}/CHANGELOG.md +21 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/PKG-INFO +1 -1
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/pyproject.toml +1 -1
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/device.py +19 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/multizone_handlers.py +19 -15
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/tile_handlers.py +59 -52
- lifx_emulator_core-3.2.0/tests/test_partial_responses.py +301 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_scenario_manager.py +27 -14
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_server.py +2 -2
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/.gitignore +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/README.md +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/constants.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/manager.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/observers.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/persistence.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/states.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/builder.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/default_config.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/factory.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/base.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/registry.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/generator.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/registry.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/specs.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/specs.yml +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/base.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/const.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/generator.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/header.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/packets.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/serializer.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/repositories/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/manager.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/models.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/server.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/conftest.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_async_storage.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_backwards_compatibility.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device_edge_cases.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device_handlers_extended.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_device_manager.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_handler_registry.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_integration.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_light_handlers_extended.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_multizone_handlers_extended.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_observers.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_products_generator.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_products_specs.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_protocol_generator.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_protocol_types_coverage.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_repositories.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_scenario_persistence.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_serializer.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_state_restorer.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_switch_devices.py +0 -0
- {lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/tests/test_tile_handlers_extended.py +0 -0
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v3.2.0 (2026-02-02)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **tests**: Replace flaky probabilistic drop rate tests with deterministic mocks
|
|
10
|
+
([`5070d6e`](https://github.com/Djelibeybi/lifx-emulator/commit/5070d6e88495dfeffaffb8f54f6eb5e6098d0f43))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **core**: Wire partial_responses scenario through packet handlers
|
|
15
|
+
([`a799dba`](https://github.com/Djelibeybi/lifx-emulator/commit/a799dba3f2d04501dd5b0694359e3e71e2ed5bbb))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## v3.1.1 (2026-02-01)
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
- **tests**: Relax timing threshold in test_no_delay_by_default
|
|
23
|
+
([`dd1b416`](https://github.com/Djelibeybi/lifx-emulator/commit/dd1b4162e7b6111a13818aad0450f15367c7c9ac))
|
|
24
|
+
|
|
25
|
+
|
|
5
26
|
## v3.1.0 (2026-01-11)
|
|
6
27
|
|
|
7
28
|
### Features
|
|
@@ -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
|
|
|
@@ -304,6 +305,24 @@ class EmulatedLifxDevice:
|
|
|
304
305
|
|
|
305
306
|
# Handle specific packet types - handlers always return list
|
|
306
307
|
response_packets = self._handle_packet_type(header, packet)
|
|
308
|
+
|
|
309
|
+
# Apply partial_responses: truncate multi-packet responses to random subset
|
|
310
|
+
if len(response_packets) > 1:
|
|
311
|
+
first_pkt = response_packets[0]
|
|
312
|
+
if (
|
|
313
|
+
hasattr(first_pkt, "PKT_TYPE")
|
|
314
|
+
and first_pkt.PKT_TYPE in scenario.partial_responses
|
|
315
|
+
):
|
|
316
|
+
original_count = len(response_packets)
|
|
317
|
+
partial_count = random.randint(1, original_count - 1) # nosec
|
|
318
|
+
response_packets = response_packets[:partial_count]
|
|
319
|
+
logger.info(
|
|
320
|
+
"Sending partial response for packet type %s (%d of %d packets)",
|
|
321
|
+
first_pkt.PKT_TYPE,
|
|
322
|
+
partial_count,
|
|
323
|
+
original_count,
|
|
324
|
+
)
|
|
325
|
+
|
|
307
326
|
# Handlers now always return list (empty if no response)
|
|
308
327
|
for resp_packet in response_packets:
|
|
309
328
|
# Cache packed payload to avoid double packing (performance optimization)
|
|
@@ -124,22 +124,26 @@ class ExtendedGetColorZonesHandler(PacketHandler):
|
|
|
124
124
|
if not device_state.has_multizone:
|
|
125
125
|
return []
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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):
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/tile_handlers.py
RENAMED
|
@@ -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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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):
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Tests for partial_responses, extended multizone >82 zones, and Get64 length."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from lifx_emulator.factories import create_multizone_light, create_tile_device
|
|
6
|
+
from lifx_emulator.protocol.header import LifxHeader
|
|
7
|
+
from lifx_emulator.protocol.packets import MultiZone, Tile
|
|
8
|
+
from lifx_emulator.protocol.protocol_types import TileBufferRect
|
|
9
|
+
from lifx_emulator.scenarios import HierarchicalScenarioManager, ScenarioConfig
|
|
10
|
+
|
|
11
|
+
# --- Helpers ---
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_header(device, pkt_type, res_required=True):
|
|
15
|
+
return LifxHeader(
|
|
16
|
+
source=1,
|
|
17
|
+
target=device.state.get_target_bytes(),
|
|
18
|
+
sequence=1,
|
|
19
|
+
pkt_type=pkt_type,
|
|
20
|
+
res_required=res_required,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _responses_of_type(responses, pkt_type):
|
|
25
|
+
return [r for r in responses if r[0].pkt_type == pkt_type]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- Extended multizone fix tests ---
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestExtendedMultizoneMultiplePackets:
|
|
32
|
+
"""ExtendedGetColorZonesHandler should return multiple packets for >82 zones."""
|
|
33
|
+
|
|
34
|
+
def test_120_zones_returns_two_packets(self):
|
|
35
|
+
device = create_multizone_light(
|
|
36
|
+
"d073d5100001", zone_count=120, extended_multizone=True
|
|
37
|
+
)
|
|
38
|
+
header = _make_header(device, 511)
|
|
39
|
+
responses = device.process_packet(header, None)
|
|
40
|
+
|
|
41
|
+
ext_responses = _responses_of_type(responses, 512)
|
|
42
|
+
assert len(ext_responses) == 2
|
|
43
|
+
|
|
44
|
+
_, pkt0 = ext_responses[0]
|
|
45
|
+
assert pkt0.index == 0
|
|
46
|
+
assert pkt0.colors_count == 82
|
|
47
|
+
assert pkt0.count == 120
|
|
48
|
+
|
|
49
|
+
_, pkt1 = ext_responses[1]
|
|
50
|
+
assert pkt1.index == 82
|
|
51
|
+
assert pkt1.colors_count == 38
|
|
52
|
+
assert pkt1.count == 120
|
|
53
|
+
|
|
54
|
+
def test_60_zones_returns_one_packet(self):
|
|
55
|
+
device = create_multizone_light(
|
|
56
|
+
"d073d5100002", zone_count=60, extended_multizone=True
|
|
57
|
+
)
|
|
58
|
+
header = _make_header(device, 511)
|
|
59
|
+
responses = device.process_packet(header, None)
|
|
60
|
+
|
|
61
|
+
ext_responses = _responses_of_type(responses, 512)
|
|
62
|
+
assert len(ext_responses) == 1
|
|
63
|
+
assert ext_responses[0][1].colors_count == 60
|
|
64
|
+
|
|
65
|
+
def test_82_zones_returns_one_packet(self):
|
|
66
|
+
device = create_multizone_light(
|
|
67
|
+
"d073d5100003", zone_count=82, extended_multizone=True
|
|
68
|
+
)
|
|
69
|
+
header = _make_header(device, 511)
|
|
70
|
+
responses = device.process_packet(header, None)
|
|
71
|
+
|
|
72
|
+
ext_responses = _responses_of_type(responses, 512)
|
|
73
|
+
assert len(ext_responses) == 1
|
|
74
|
+
assert ext_responses[0][1].colors_count == 82
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- Get64 length fix tests ---
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestGet64Length:
|
|
81
|
+
"""Get64Handler should respect the length field."""
|
|
82
|
+
|
|
83
|
+
def test_length_1(self):
|
|
84
|
+
device = create_tile_device("d073d5200001", tile_count=5)
|
|
85
|
+
packet = Tile.Get64(
|
|
86
|
+
tile_index=0,
|
|
87
|
+
length=1,
|
|
88
|
+
rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
|
|
89
|
+
)
|
|
90
|
+
header = _make_header(device, 707)
|
|
91
|
+
responses = device.process_packet(header, packet)
|
|
92
|
+
|
|
93
|
+
tile_responses = _responses_of_type(responses, 711)
|
|
94
|
+
assert len(tile_responses) == 1
|
|
95
|
+
assert tile_responses[0][1].tile_index == 0
|
|
96
|
+
|
|
97
|
+
def test_length_3(self):
|
|
98
|
+
device = create_tile_device("d073d5200002", tile_count=5)
|
|
99
|
+
packet = Tile.Get64(
|
|
100
|
+
tile_index=0,
|
|
101
|
+
length=3,
|
|
102
|
+
rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
|
|
103
|
+
)
|
|
104
|
+
header = _make_header(device, 707)
|
|
105
|
+
responses = device.process_packet(header, packet)
|
|
106
|
+
|
|
107
|
+
tile_responses = _responses_of_type(responses, 711)
|
|
108
|
+
assert len(tile_responses) == 3
|
|
109
|
+
assert [r[1].tile_index for r in tile_responses] == [0, 1, 2]
|
|
110
|
+
|
|
111
|
+
def test_length_5_full_chain(self):
|
|
112
|
+
device = create_tile_device("d073d5200003", tile_count=5)
|
|
113
|
+
packet = Tile.Get64(
|
|
114
|
+
tile_index=0,
|
|
115
|
+
length=5,
|
|
116
|
+
rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
|
|
117
|
+
)
|
|
118
|
+
header = _make_header(device, 707)
|
|
119
|
+
responses = device.process_packet(header, packet)
|
|
120
|
+
|
|
121
|
+
tile_responses = _responses_of_type(responses, 711)
|
|
122
|
+
assert len(tile_responses) == 5
|
|
123
|
+
assert [r[1].tile_index for r in tile_responses] == [0, 1, 2, 3, 4]
|
|
124
|
+
|
|
125
|
+
def test_length_exceeds_chain(self):
|
|
126
|
+
device = create_tile_device("d073d5200004", tile_count=5)
|
|
127
|
+
packet = Tile.Get64(
|
|
128
|
+
tile_index=3,
|
|
129
|
+
length=5,
|
|
130
|
+
rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
|
|
131
|
+
)
|
|
132
|
+
header = _make_header(device, 707)
|
|
133
|
+
responses = device.process_packet(header, packet)
|
|
134
|
+
|
|
135
|
+
tile_responses = _responses_of_type(responses, 711)
|
|
136
|
+
assert len(tile_responses) == 2
|
|
137
|
+
assert [r[1].tile_index for r in tile_responses] == [3, 4]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- Partial response tests ---
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestPartialResponsesMultizone:
|
|
144
|
+
"""partial_responses should truncate multi-packet multizone responses."""
|
|
145
|
+
|
|
146
|
+
def test_standard_multizone_partial(self):
|
|
147
|
+
"""120 zones = 15 StateMultiZone packets; partial should return 1..14."""
|
|
148
|
+
device = create_multizone_light("d073d5300001", zone_count=120)
|
|
149
|
+
scenario_manager = HierarchicalScenarioManager()
|
|
150
|
+
scenario_manager.set_device_scenario(
|
|
151
|
+
"d073d5300001", ScenarioConfig(partial_responses=[506])
|
|
152
|
+
)
|
|
153
|
+
device.scenario_manager = scenario_manager
|
|
154
|
+
device.invalidate_scenario_cache()
|
|
155
|
+
|
|
156
|
+
packet = MultiZone.GetColorZones(start_index=0, end_index=119)
|
|
157
|
+
header = _make_header(device, 502)
|
|
158
|
+
responses = device.process_packet(header, packet)
|
|
159
|
+
|
|
160
|
+
mz_responses = _responses_of_type(responses, 506)
|
|
161
|
+
assert 1 <= len(mz_responses) < 15
|
|
162
|
+
|
|
163
|
+
def test_standard_multizone_no_partial(self):
|
|
164
|
+
"""Without partial_responses, all 15 packets should be returned."""
|
|
165
|
+
device = create_multizone_light("d073d5300002", zone_count=120)
|
|
166
|
+
packet = MultiZone.GetColorZones(start_index=0, end_index=119)
|
|
167
|
+
header = _make_header(device, 502)
|
|
168
|
+
responses = device.process_packet(header, packet)
|
|
169
|
+
|
|
170
|
+
mz_responses = _responses_of_type(responses, 506)
|
|
171
|
+
assert len(mz_responses) == 15
|
|
172
|
+
|
|
173
|
+
def test_extended_multizone_partial(self):
|
|
174
|
+
"""120 zones = 2 ExtendedStateMultiZone; partial should return 1."""
|
|
175
|
+
device = create_multizone_light(
|
|
176
|
+
"d073d5300003", zone_count=120, extended_multizone=True
|
|
177
|
+
)
|
|
178
|
+
scenario_manager = HierarchicalScenarioManager()
|
|
179
|
+
scenario_manager.set_device_scenario(
|
|
180
|
+
"d073d5300003", ScenarioConfig(partial_responses=[512])
|
|
181
|
+
)
|
|
182
|
+
device.scenario_manager = scenario_manager
|
|
183
|
+
device.invalidate_scenario_cache()
|
|
184
|
+
|
|
185
|
+
header = _make_header(device, 511)
|
|
186
|
+
responses = device.process_packet(header, None)
|
|
187
|
+
|
|
188
|
+
ext_responses = _responses_of_type(responses, 512)
|
|
189
|
+
# 2 packets, partial -> exactly 1 (randint(1,1) = 1)
|
|
190
|
+
assert len(ext_responses) == 1
|
|
191
|
+
|
|
192
|
+
def test_extended_multizone_not_affected_by_standard_partial(self):
|
|
193
|
+
"""partial_responses=[506] should not affect ExtendedStateMultiZone (512)."""
|
|
194
|
+
device = create_multizone_light(
|
|
195
|
+
"d073d5300004", zone_count=120, extended_multizone=True
|
|
196
|
+
)
|
|
197
|
+
scenario_manager = HierarchicalScenarioManager()
|
|
198
|
+
scenario_manager.set_device_scenario(
|
|
199
|
+
"d073d5300004", ScenarioConfig(partial_responses=[506])
|
|
200
|
+
)
|
|
201
|
+
device.scenario_manager = scenario_manager
|
|
202
|
+
device.invalidate_scenario_cache()
|
|
203
|
+
|
|
204
|
+
header = _make_header(device, 511)
|
|
205
|
+
responses = device.process_packet(header, None)
|
|
206
|
+
|
|
207
|
+
ext_responses = _responses_of_type(responses, 512)
|
|
208
|
+
assert len(ext_responses) == 2
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestPartialResponsesTile:
|
|
212
|
+
"""partial_responses should truncate multi-packet tile responses."""
|
|
213
|
+
|
|
214
|
+
def test_tile_partial(self):
|
|
215
|
+
"""5 tiles = 5 State64 packets; partial should return 1..4."""
|
|
216
|
+
device = create_tile_device("d073d5400001", tile_count=5)
|
|
217
|
+
scenario_manager = HierarchicalScenarioManager()
|
|
218
|
+
scenario_manager.set_device_scenario(
|
|
219
|
+
"d073d5400001", ScenarioConfig(partial_responses=[711])
|
|
220
|
+
)
|
|
221
|
+
device.scenario_manager = scenario_manager
|
|
222
|
+
device.invalidate_scenario_cache()
|
|
223
|
+
|
|
224
|
+
packet = Tile.Get64(
|
|
225
|
+
tile_index=0,
|
|
226
|
+
length=5,
|
|
227
|
+
rect=TileBufferRect(fb_index=0, x=0, y=0, width=8),
|
|
228
|
+
)
|
|
229
|
+
header = _make_header(device, 707)
|
|
230
|
+
responses = device.process_packet(header, packet)
|
|
231
|
+
|
|
232
|
+
tile_responses = _responses_of_type(responses, 711)
|
|
233
|
+
assert 1 <= len(tile_responses) < 5
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TestPartialResponsesDeterministic:
|
|
237
|
+
"""Verify partial truncation with mocked randomness."""
|
|
238
|
+
|
|
239
|
+
def test_deterministic_truncation(self):
|
|
240
|
+
"""With randint mocked to return 3, should get exactly 3 packets."""
|
|
241
|
+
device = create_multizone_light("d073d5500001", zone_count=120)
|
|
242
|
+
scenario_manager = HierarchicalScenarioManager()
|
|
243
|
+
scenario_manager.set_device_scenario(
|
|
244
|
+
"d073d5500001", ScenarioConfig(partial_responses=[506])
|
|
245
|
+
)
|
|
246
|
+
device.scenario_manager = scenario_manager
|
|
247
|
+
device.invalidate_scenario_cache()
|
|
248
|
+
|
|
249
|
+
packet = MultiZone.GetColorZones(start_index=0, end_index=119)
|
|
250
|
+
header = _make_header(device, 502)
|
|
251
|
+
|
|
252
|
+
with patch("lifx_emulator.devices.device.random.randint", return_value=3):
|
|
253
|
+
responses = device.process_packet(header, packet)
|
|
254
|
+
|
|
255
|
+
mz_responses = _responses_of_type(responses, 506)
|
|
256
|
+
assert len(mz_responses) == 3
|
|
257
|
+
|
|
258
|
+
def test_partial_coexists_with_other_scenarios(self):
|
|
259
|
+
"""partial_responses should work alongside response_delays."""
|
|
260
|
+
device = create_multizone_light("d073d5500002", zone_count=120)
|
|
261
|
+
scenario_manager = HierarchicalScenarioManager()
|
|
262
|
+
scenario_manager.set_device_scenario(
|
|
263
|
+
"d073d5500002",
|
|
264
|
+
ScenarioConfig(
|
|
265
|
+
partial_responses=[506],
|
|
266
|
+
response_delays={506: 0.01},
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
device.scenario_manager = scenario_manager
|
|
270
|
+
device.invalidate_scenario_cache()
|
|
271
|
+
|
|
272
|
+
packet = MultiZone.GetColorZones(start_index=0, end_index=119)
|
|
273
|
+
header = _make_header(device, 502)
|
|
274
|
+
|
|
275
|
+
with patch("lifx_emulator.devices.device.random.randint", return_value=5):
|
|
276
|
+
responses = device.process_packet(header, packet)
|
|
277
|
+
|
|
278
|
+
mz_responses = _responses_of_type(responses, 506)
|
|
279
|
+
assert len(mz_responses) == 5
|
|
280
|
+
|
|
281
|
+
def test_randomness_varies(self):
|
|
282
|
+
"""Without mocking, repeated runs should not all return the same count."""
|
|
283
|
+
device = create_multizone_light("d073d5500003", zone_count=120)
|
|
284
|
+
scenario_manager = HierarchicalScenarioManager()
|
|
285
|
+
scenario_manager.set_device_scenario(
|
|
286
|
+
"d073d5500003", ScenarioConfig(partial_responses=[506])
|
|
287
|
+
)
|
|
288
|
+
device.scenario_manager = scenario_manager
|
|
289
|
+
device.invalidate_scenario_cache()
|
|
290
|
+
|
|
291
|
+
packet = MultiZone.GetColorZones(start_index=0, end_index=119)
|
|
292
|
+
header = _make_header(device, 502)
|
|
293
|
+
|
|
294
|
+
counts = set()
|
|
295
|
+
for _ in range(20):
|
|
296
|
+
responses = device.process_packet(header, packet)
|
|
297
|
+
mz_responses = _responses_of_type(responses, 506)
|
|
298
|
+
counts.add(len(mz_responses))
|
|
299
|
+
|
|
300
|
+
# With 14 possible values (1..14), 20 runs should produce >1 unique count
|
|
301
|
+
assert len(counts) > 1
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Tests for hierarchical scenario manager."""
|
|
2
2
|
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
3
5
|
from lifx_emulator.factories import (
|
|
4
6
|
create_color_light,
|
|
5
7
|
create_multizone_light,
|
|
@@ -293,29 +295,40 @@ class TestScenarioManagerMethods:
|
|
|
293
295
|
assert manager.should_respond(102, scenario) is False
|
|
294
296
|
assert manager.should_respond(103, scenario) is True
|
|
295
297
|
|
|
296
|
-
def
|
|
297
|
-
"""Test
|
|
298
|
+
def test_should_respond_drops_below_threshold(self):
|
|
299
|
+
"""Test that a random roll below the drop rate causes a drop."""
|
|
298
300
|
manager = HierarchicalScenarioManager()
|
|
299
301
|
scenario = ScenarioConfig(drop_packets={101: 0.5})
|
|
300
302
|
|
|
301
|
-
#
|
|
302
|
-
|
|
303
|
-
|
|
303
|
+
# random() returns 0.3, which is < 0.5 drop_rate → should drop
|
|
304
|
+
with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.3):
|
|
305
|
+
assert manager.should_respond(101, scenario) is False
|
|
304
306
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
def test_should_respond_keeps_at_or_above_threshold(self):
|
|
308
|
+
"""Test that a random roll at or above the drop rate keeps the packet."""
|
|
309
|
+
manager = HierarchicalScenarioManager()
|
|
310
|
+
scenario = ScenarioConfig(drop_packets={101: 0.5})
|
|
307
311
|
|
|
308
|
-
|
|
309
|
-
""
|
|
312
|
+
# random() returns 0.5, which is >= 0.5 drop_rate → should keep
|
|
313
|
+
with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.5):
|
|
314
|
+
assert manager.should_respond(101, scenario) is True
|
|
315
|
+
|
|
316
|
+
# random() returns 0.9, which is >= 0.5 drop_rate → should keep
|
|
317
|
+
with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.9):
|
|
318
|
+
assert manager.should_respond(101, scenario) is True
|
|
319
|
+
|
|
320
|
+
def test_should_respond_boundary_with_low_drop_rate(self):
|
|
321
|
+
"""Test boundary behavior with a low drop rate."""
|
|
310
322
|
manager = HierarchicalScenarioManager()
|
|
311
323
|
scenario = ScenarioConfig(drop_packets={101: 0.1})
|
|
312
324
|
|
|
313
|
-
#
|
|
314
|
-
|
|
315
|
-
|
|
325
|
+
# random() returns 0.09, which is < 0.1 → drop
|
|
326
|
+
with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.09):
|
|
327
|
+
assert manager.should_respond(101, scenario) is False
|
|
316
328
|
|
|
317
|
-
#
|
|
318
|
-
|
|
329
|
+
# random() returns 0.1, which is >= 0.1 → keep
|
|
330
|
+
with patch("lifx_emulator.scenarios.manager.random.random", return_value=0.1):
|
|
331
|
+
assert manager.should_respond(101, scenario) is True
|
|
319
332
|
|
|
320
333
|
def test_get_response_delay(self):
|
|
321
334
|
"""Test response delay retrieval."""
|
|
@@ -298,8 +298,8 @@ class TestResponseDelays:
|
|
|
298
298
|
await server.handle_packet(packet_data, addr)
|
|
299
299
|
elapsed = time.time() - start_time
|
|
300
300
|
|
|
301
|
-
# Should be very fast (<
|
|
302
|
-
assert elapsed < 0.
|
|
301
|
+
# Should be very fast (< 100ms, generous for CI runners)
|
|
302
|
+
assert elapsed < 0.1
|
|
303
303
|
|
|
304
304
|
|
|
305
305
|
class TestServerLifecycle:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/observers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/persistence.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/state_restorer.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/devices/state_serializer.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/builder.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/default_config.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/factory.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/factories/firmware_config.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/device_handlers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/light_handlers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/handlers/registry.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/generator.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/products/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/generator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/protocol_types.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/protocol/serializer.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/repositories/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/manager.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.0}/src/lifx_emulator/scenarios/persistence.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.1.0 → lifx_emulator_core-3.2.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.1.0 → lifx_emulator_core-3.2.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
|