lifx-emulator-core 3.4.0__tar.gz → 3.5.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.4.0 → lifx_emulator_core-3.5.0}/.gitignore +0 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/CHANGELOG.md +16 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/PKG-INFO +1 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/pyproject.toml +1 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/__init__.py +8 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/manager.py +44 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/manager.py +8 -7
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/models.py +1 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/server.py +4 -3
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device_manager.py +123 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_scenario_manager.py +1 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_scenario_persistence.py +1 -1
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_server.py +98 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/README.md +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/__init__.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/constants.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/device.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/observers.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/persistence.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/states.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/__init__.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/builder.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/default_config.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/factory.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/__init__.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/base.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/multizone_handlers.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/registry.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/tile_handlers.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/__init__.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/generator.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/registry.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/specs.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/specs.yml +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/__init__.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/base.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/const.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/generator.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/header.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/packets.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/serializer.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/repositories/__init__.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/conftest.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_async_storage.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_backwards_compatibility.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device_edge_cases.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device_handlers_extended.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_handler_registry.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_integration.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_light_handlers_extended.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_multizone_handlers_extended.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_observers.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_partial_responses.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_products_generator.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_products_specs.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_protocol_generator.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_protocol_types_coverage.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_repositories.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_serializer.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_state_restorer.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_switch_devices.py +0 -0
- {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_tile_handlers_extended.py +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v3.5.0 (2026-02-03)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Address CodeRabbit PR review feedback
|
|
10
|
+
([`e302407`](https://github.com/Djelibeybi/lifx-emulator/commit/e302407e19f73de9a49361372b5c776b761d61e1))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **api**: Complete WebSocket real-time event infrastructure
|
|
15
|
+
([`81cfa6f`](https://github.com/Djelibeybi/lifx-emulator/commit/81cfa6f11dfb7d216b40d6672ba73883841be11a))
|
|
16
|
+
|
|
17
|
+
- **dashboard**: Add scenario panel, pagination, and tabbed interface (Phase 4)
|
|
18
|
+
([`a50951d`](https://github.com/Djelibeybi/lifx-emulator/commit/a50951d805592787ea70a86441e44c781e307c1c))
|
|
19
|
+
|
|
20
|
+
|
|
5
21
|
## v3.4.0 (2026-02-03)
|
|
6
22
|
|
|
7
23
|
### Features
|
|
@@ -10,7 +10,12 @@ This module contains all device-related functionality including:
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from lifx_emulator.devices.device import EmulatedLifxDevice
|
|
13
|
-
from lifx_emulator.devices.manager import
|
|
13
|
+
from lifx_emulator.devices.manager import (
|
|
14
|
+
DeviceAddedCallback,
|
|
15
|
+
DeviceManager,
|
|
16
|
+
DeviceRemovedCallback,
|
|
17
|
+
IDeviceManager,
|
|
18
|
+
)
|
|
14
19
|
from lifx_emulator.devices.observers import (
|
|
15
20
|
ActivityLogger,
|
|
16
21
|
ActivityObserver,
|
|
@@ -26,6 +31,8 @@ from lifx_emulator.devices.states import DeviceState
|
|
|
26
31
|
__all__ = [
|
|
27
32
|
"EmulatedLifxDevice",
|
|
28
33
|
"DeviceManager",
|
|
34
|
+
"DeviceAddedCallback",
|
|
35
|
+
"DeviceRemovedCallback",
|
|
29
36
|
"IDeviceManager",
|
|
30
37
|
"DeviceState",
|
|
31
38
|
"DevicePersistenceAsyncFile",
|
|
@@ -8,6 +8,7 @@ concerns principle by extracting domain logic from the network layer.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
11
12
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
@@ -18,6 +19,10 @@ if TYPE_CHECKING:
|
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger(__name__)
|
|
20
21
|
|
|
22
|
+
# Type aliases for device lifecycle callbacks
|
|
23
|
+
DeviceAddedCallback = Callable[["EmulatedLifxDevice"], None]
|
|
24
|
+
DeviceRemovedCallback = Callable[[str], None]
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
@runtime_checkable
|
|
23
28
|
class IDeviceManager(Protocol):
|
|
@@ -116,15 +121,27 @@ class DeviceManager:
|
|
|
116
121
|
This class extracts device management logic from EmulatedLifxServer,
|
|
117
122
|
providing a clean separation between domain logic and network I/O.
|
|
118
123
|
It mirrors the architecture of HierarchicalScenarioManager.
|
|
124
|
+
|
|
125
|
+
Supports optional callbacks for device lifecycle events, allowing
|
|
126
|
+
external systems (like WebSocket managers) to be notified of changes.
|
|
119
127
|
"""
|
|
120
128
|
|
|
121
|
-
def __init__(
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
device_repository: IDeviceRepository,
|
|
132
|
+
on_device_added: DeviceAddedCallback | None = None,
|
|
133
|
+
on_device_removed: DeviceRemovedCallback | None = None,
|
|
134
|
+
):
|
|
122
135
|
"""Initialize the device manager.
|
|
123
136
|
|
|
124
137
|
Args:
|
|
125
138
|
device_repository: Repository for device storage and retrieval
|
|
139
|
+
on_device_added: Optional callback invoked when a device is added
|
|
140
|
+
on_device_removed: Optional callback invoked when a device is removed
|
|
126
141
|
"""
|
|
127
142
|
self._device_repository = device_repository
|
|
143
|
+
self.on_device_added = on_device_added
|
|
144
|
+
self.on_device_removed = on_device_removed
|
|
128
145
|
|
|
129
146
|
def add_device(
|
|
130
147
|
self,
|
|
@@ -152,6 +169,11 @@ class DeviceManager:
|
|
|
152
169
|
if success:
|
|
153
170
|
serial = device.state.serial
|
|
154
171
|
logger.info("Added device: %s (product=%s)", serial, device.state.product)
|
|
172
|
+
if self.on_device_added is not None:
|
|
173
|
+
try:
|
|
174
|
+
self.on_device_added(device)
|
|
175
|
+
except Exception:
|
|
176
|
+
logger.exception("Error in on_device_added callback for %s", serial)
|
|
155
177
|
return success
|
|
156
178
|
|
|
157
179
|
def remove_device(self, serial: str, storage=None) -> bool:
|
|
@@ -172,6 +194,14 @@ class DeviceManager:
|
|
|
172
194
|
if storage:
|
|
173
195
|
storage.delete_device_state(serial)
|
|
174
196
|
|
|
197
|
+
if self.on_device_removed is not None:
|
|
198
|
+
try:
|
|
199
|
+
self.on_device_removed(serial)
|
|
200
|
+
except Exception:
|
|
201
|
+
logger.exception(
|
|
202
|
+
"Error in on_device_removed callback for %s", serial
|
|
203
|
+
)
|
|
204
|
+
|
|
175
205
|
return success
|
|
176
206
|
|
|
177
207
|
def remove_all_devices(self, delete_storage: bool = False, storage=None) -> int:
|
|
@@ -184,6 +214,9 @@ class DeviceManager:
|
|
|
184
214
|
Returns:
|
|
185
215
|
Number of devices removed
|
|
186
216
|
"""
|
|
217
|
+
# Get all device serials before clearing (for callbacks)
|
|
218
|
+
serials = [d.state.serial for d in self._device_repository.get_all()]
|
|
219
|
+
|
|
187
220
|
# Clear all devices from repository
|
|
188
221
|
device_count = self._device_repository.clear()
|
|
189
222
|
logger.info("Removed all %s device(s)", device_count)
|
|
@@ -193,6 +226,16 @@ class DeviceManager:
|
|
|
193
226
|
deleted = storage.delete_all_device_states()
|
|
194
227
|
logger.info("Deleted %s device state(s) from persistent storage", deleted)
|
|
195
228
|
|
|
229
|
+
# Notify callbacks for each removed device
|
|
230
|
+
if self.on_device_removed is not None:
|
|
231
|
+
for serial in serials:
|
|
232
|
+
try:
|
|
233
|
+
self.on_device_removed(serial)
|
|
234
|
+
except Exception:
|
|
235
|
+
logger.exception(
|
|
236
|
+
"Error in on_device_removed callback for %s", serial
|
|
237
|
+
)
|
|
238
|
+
|
|
196
239
|
return device_count
|
|
197
240
|
|
|
198
241
|
def get_device(self, serial: str) -> EmulatedLifxDevice | None:
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/manager.py
RENAMED
|
@@ -60,8 +60,9 @@ class HierarchicalScenarioManager:
|
|
|
60
60
|
self.type_scenarios: dict[str, ScenarioConfig] = {} # type → config
|
|
61
61
|
self.location_scenarios: dict[str, ScenarioConfig] = {} # location → config
|
|
62
62
|
self.group_scenarios: dict[str, ScenarioConfig] = {} # group → config
|
|
63
|
+
# Default global scenario: send_unhandled=True matches real LIFX behavior
|
|
63
64
|
self.global_scenario: ScenarioConfig = ScenarioConfig(
|
|
64
|
-
firmware_version=None, send_unhandled=
|
|
65
|
+
firmware_version=None, send_unhandled=True
|
|
65
66
|
)
|
|
66
67
|
|
|
67
68
|
def set_device_scenario(self, serial: str, config: ScenarioConfig):
|
|
@@ -101,9 +102,9 @@ class HierarchicalScenarioManager:
|
|
|
101
102
|
return self.group_scenarios.pop(group, None) is not None
|
|
102
103
|
|
|
103
104
|
def clear_global_scenario(self):
|
|
104
|
-
"""Clear global scenario (reset to
|
|
105
|
+
"""Clear global scenario (reset to default with send_unhandled=True)."""
|
|
105
106
|
self.global_scenario = ScenarioConfig(
|
|
106
|
-
firmware_version=None, send_unhandled=
|
|
107
|
+
firmware_version=None, send_unhandled=True
|
|
107
108
|
)
|
|
108
109
|
|
|
109
110
|
def get_global_scenario(self) -> ScenarioConfig:
|
|
@@ -184,8 +185,8 @@ class HierarchicalScenarioManager:
|
|
|
184
185
|
Returns:
|
|
185
186
|
Merged ScenarioConfig
|
|
186
187
|
"""
|
|
187
|
-
# Start with
|
|
188
|
-
merged = ScenarioConfig(firmware_version=None, send_unhandled=
|
|
188
|
+
# Start with default config (send_unhandled=True matches real LIFX behavior)
|
|
189
|
+
merged = ScenarioConfig(firmware_version=None, send_unhandled=True)
|
|
189
190
|
|
|
190
191
|
# Layer in each scope (general to specific)
|
|
191
192
|
# Later scopes override or merge with earlier ones
|
|
@@ -219,8 +220,8 @@ class HierarchicalScenarioManager:
|
|
|
219
220
|
# Scalars: use most specific non-default value
|
|
220
221
|
if config.firmware_version is not None:
|
|
221
222
|
merged.firmware_version = config.firmware_version
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
# send_unhandled: most specific scope wins (explicit False overrides)
|
|
224
|
+
merged.send_unhandled = config.send_unhandled
|
|
224
225
|
|
|
225
226
|
return merged
|
|
226
227
|
|
|
@@ -51,7 +51,7 @@ class ScenarioConfig(BaseModel):
|
|
|
51
51
|
description="List of packet types to send with incomplete data",
|
|
52
52
|
)
|
|
53
53
|
send_unhandled: bool = Field(
|
|
54
|
-
|
|
54
|
+
True, description="Send unhandled message responses for unknown packet types"
|
|
55
55
|
)
|
|
56
56
|
|
|
57
57
|
@property
|
|
@@ -482,7 +482,7 @@ class EmulatedLifxServer:
|
|
|
482
482
|
"packets_received_by_type": dict(self.packets_received_by_type),
|
|
483
483
|
"packets_sent_by_type": dict(self.packets_sent_by_type),
|
|
484
484
|
"error_count": self.error_count,
|
|
485
|
-
"activity_enabled":
|
|
485
|
+
"activity_enabled": hasattr(self.activity_observer, "get_recent_activity"),
|
|
486
486
|
}
|
|
487
487
|
|
|
488
488
|
def get_recent_activity(self) -> list[dict[str, Any]]:
|
|
@@ -492,8 +492,9 @@ class EmulatedLifxServer:
|
|
|
492
492
|
List of activity event dictionaries, or empty list if observer
|
|
493
493
|
doesn't support activity tracking
|
|
494
494
|
"""
|
|
495
|
-
|
|
496
|
-
|
|
495
|
+
get_activity = getattr(self.activity_observer, "get_recent_activity", None)
|
|
496
|
+
if get_activity is not None:
|
|
497
|
+
return get_activity()
|
|
497
498
|
return []
|
|
498
499
|
|
|
499
500
|
async def start(self):
|
|
@@ -285,3 +285,126 @@ class TestDeviceManager:
|
|
|
285
285
|
|
|
286
286
|
# Verify storage deletion was NOT called
|
|
287
287
|
mock_storage.delete_all_device_states.assert_not_called()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class TestDeviceManagerCallbacks:
|
|
291
|
+
"""Test DeviceManager callback functionality."""
|
|
292
|
+
|
|
293
|
+
def test_on_device_added_callback_invoked(self):
|
|
294
|
+
"""on_device_added callback is invoked when device is added."""
|
|
295
|
+
from unittest.mock import Mock
|
|
296
|
+
|
|
297
|
+
callback = Mock()
|
|
298
|
+
manager = DeviceManager(DeviceRepository(), on_device_added=callback)
|
|
299
|
+
device = create_color_light("d073d5000001")
|
|
300
|
+
|
|
301
|
+
manager.add_device(device)
|
|
302
|
+
|
|
303
|
+
callback.assert_called_once_with(device)
|
|
304
|
+
|
|
305
|
+
def test_on_device_added_not_called_on_duplicate(self):
|
|
306
|
+
"""on_device_added callback is not called when adding duplicate device."""
|
|
307
|
+
from unittest.mock import Mock
|
|
308
|
+
|
|
309
|
+
callback = Mock()
|
|
310
|
+
manager = DeviceManager(DeviceRepository(), on_device_added=callback)
|
|
311
|
+
device = create_color_light("d073d5000001")
|
|
312
|
+
|
|
313
|
+
manager.add_device(device)
|
|
314
|
+
callback.reset_mock()
|
|
315
|
+
|
|
316
|
+
# Adding duplicate should not trigger callback
|
|
317
|
+
manager.add_device(device)
|
|
318
|
+
callback.assert_not_called()
|
|
319
|
+
|
|
320
|
+
def test_on_device_added_callback_exception_logged(self):
|
|
321
|
+
"""Exception in on_device_added callback is logged but doesn't prevent add."""
|
|
322
|
+
from unittest.mock import Mock
|
|
323
|
+
|
|
324
|
+
callback = Mock(side_effect=Exception("callback error"))
|
|
325
|
+
manager = DeviceManager(DeviceRepository(), on_device_added=callback)
|
|
326
|
+
device = create_color_light("d073d5000001")
|
|
327
|
+
|
|
328
|
+
# Should still succeed despite callback exception
|
|
329
|
+
result = manager.add_device(device)
|
|
330
|
+
assert result is True
|
|
331
|
+
assert manager.count_devices() == 1
|
|
332
|
+
|
|
333
|
+
def test_on_device_removed_callback_invoked(self):
|
|
334
|
+
"""on_device_removed callback is invoked when device is removed."""
|
|
335
|
+
from unittest.mock import Mock
|
|
336
|
+
|
|
337
|
+
callback = Mock()
|
|
338
|
+
manager = DeviceManager(DeviceRepository(), on_device_removed=callback)
|
|
339
|
+
device = create_color_light("d073d5000001")
|
|
340
|
+
manager.add_device(device)
|
|
341
|
+
|
|
342
|
+
manager.remove_device("d073d5000001")
|
|
343
|
+
|
|
344
|
+
callback.assert_called_once_with("d073d5000001")
|
|
345
|
+
|
|
346
|
+
def test_on_device_removed_not_called_on_nonexistent(self):
|
|
347
|
+
"""on_device_removed callback is not called when removing nonexistent device."""
|
|
348
|
+
from unittest.mock import Mock
|
|
349
|
+
|
|
350
|
+
callback = Mock()
|
|
351
|
+
manager = DeviceManager(DeviceRepository(), on_device_removed=callback)
|
|
352
|
+
|
|
353
|
+
# Removing nonexistent device should not trigger callback
|
|
354
|
+
manager.remove_device("d073d5000001")
|
|
355
|
+
callback.assert_not_called()
|
|
356
|
+
|
|
357
|
+
def test_on_device_removed_callback_exception_logged(self):
|
|
358
|
+
"""Exception in callback is logged but doesn't prevent remove."""
|
|
359
|
+
from unittest.mock import Mock
|
|
360
|
+
|
|
361
|
+
callback = Mock(side_effect=Exception("callback error"))
|
|
362
|
+
manager = DeviceManager(DeviceRepository(), on_device_removed=callback)
|
|
363
|
+
device = create_color_light("d073d5000001")
|
|
364
|
+
manager.add_device(device)
|
|
365
|
+
|
|
366
|
+
# Should still succeed despite callback exception
|
|
367
|
+
result = manager.remove_device("d073d5000001")
|
|
368
|
+
assert result is True
|
|
369
|
+
assert manager.count_devices() == 0
|
|
370
|
+
|
|
371
|
+
def test_remove_all_devices_invokes_callback_for_each(self):
|
|
372
|
+
"""on_device_removed callback is invoked for each device when removing all."""
|
|
373
|
+
from unittest.mock import Mock
|
|
374
|
+
|
|
375
|
+
callback = Mock()
|
|
376
|
+
manager = DeviceManager(DeviceRepository(), on_device_removed=callback)
|
|
377
|
+
|
|
378
|
+
# Add multiple devices
|
|
379
|
+
manager.add_device(create_color_light("d073d5000001"))
|
|
380
|
+
manager.add_device(create_color_light("d073d5000002"))
|
|
381
|
+
manager.add_device(create_color_light("d073d5000003"))
|
|
382
|
+
|
|
383
|
+
manager.remove_all_devices()
|
|
384
|
+
|
|
385
|
+
assert callback.call_count == 3
|
|
386
|
+
# Verify all serials were passed to callback
|
|
387
|
+
called_serials = {call.args[0] for call in callback.call_args_list}
|
|
388
|
+
assert called_serials == {"d073d5000001", "d073d5000002", "d073d5000003"}
|
|
389
|
+
|
|
390
|
+
def test_both_callbacks_can_be_set(self):
|
|
391
|
+
"""Both on_device_added and on_device_removed callbacks can be set."""
|
|
392
|
+
from unittest.mock import Mock
|
|
393
|
+
|
|
394
|
+
add_callback = Mock()
|
|
395
|
+
remove_callback = Mock()
|
|
396
|
+
manager = DeviceManager(
|
|
397
|
+
DeviceRepository(),
|
|
398
|
+
on_device_added=add_callback,
|
|
399
|
+
on_device_removed=remove_callback,
|
|
400
|
+
)
|
|
401
|
+
device = create_color_light("d073d5000001")
|
|
402
|
+
|
|
403
|
+
manager.add_device(device)
|
|
404
|
+
add_callback.assert_called_once_with(device)
|
|
405
|
+
remove_callback.assert_not_called()
|
|
406
|
+
|
|
407
|
+
add_callback.reset_mock()
|
|
408
|
+
manager.remove_device("d073d5000001")
|
|
409
|
+
add_callback.assert_not_called()
|
|
410
|
+
remove_callback.assert_called_once_with("d073d5000001")
|
|
@@ -26,7 +26,7 @@ class TestScenarioConfig:
|
|
|
26
26
|
assert config.invalid_field_values == []
|
|
27
27
|
assert config.firmware_version is None
|
|
28
28
|
assert config.partial_responses == []
|
|
29
|
-
assert config.send_unhandled is
|
|
29
|
+
assert config.send_unhandled is True # Default is on
|
|
30
30
|
|
|
31
31
|
def test_scenario_config_to_dict(self):
|
|
32
32
|
"""Test conversion to dictionary."""
|
|
@@ -37,7 +37,7 @@ class TestScenarioPersistenceAsyncFile:
|
|
|
37
37
|
"invalid_field_values": [],
|
|
38
38
|
"firmware_version": None,
|
|
39
39
|
"partial_responses": [],
|
|
40
|
-
"send_unhandled":
|
|
40
|
+
"send_unhandled": True, # Default is on
|
|
41
41
|
}
|
|
42
42
|
assert data["devices"] == {}
|
|
43
43
|
assert data["types"] == {}
|
|
@@ -693,3 +693,101 @@ class TestServerAckBehavior:
|
|
|
693
693
|
sent_data = server.transport.sendto.call_args_list[0][0][0]
|
|
694
694
|
resp_header = LifxHeader.unpack(sent_data)
|
|
695
695
|
assert resp_header.pkt_type == 25 # StateLabel
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class TestServerStatsAndActivity:
|
|
699
|
+
"""Tests for server stats and activity tracking."""
|
|
700
|
+
|
|
701
|
+
def test_get_stats_with_activity_enabled(self):
|
|
702
|
+
"""Test get_stats() includes activity_enabled when observer supports it."""
|
|
703
|
+
from lifx_emulator.devices import ActivityLogger
|
|
704
|
+
|
|
705
|
+
device_manager = DeviceManager(DeviceRepository())
|
|
706
|
+
activity_logger = ActivityLogger(max_events=100)
|
|
707
|
+
server = EmulatedLifxServer(
|
|
708
|
+
[], device_manager, "127.0.0.1", 56700, activity_observer=activity_logger
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
stats = server.get_stats()
|
|
712
|
+
assert stats["activity_enabled"] is True
|
|
713
|
+
assert "uptime_seconds" in stats
|
|
714
|
+
assert "packets_received" in stats
|
|
715
|
+
|
|
716
|
+
def test_get_stats_with_activity_disabled(self):
|
|
717
|
+
"""Test get_stats() shows activity disabled with NullObserver."""
|
|
718
|
+
device_manager = DeviceManager(DeviceRepository())
|
|
719
|
+
server = EmulatedLifxServer(
|
|
720
|
+
[], device_manager, "127.0.0.1", 56700, track_activity=False
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
stats = server.get_stats()
|
|
724
|
+
assert stats["activity_enabled"] is False
|
|
725
|
+
|
|
726
|
+
def test_get_recent_activity_with_logger(self):
|
|
727
|
+
"""Test get_recent_activity() returns events when observer supports it."""
|
|
728
|
+
from lifx_emulator.devices import ActivityLogger, PacketEvent
|
|
729
|
+
|
|
730
|
+
device_manager = DeviceManager(DeviceRepository())
|
|
731
|
+
activity_logger = ActivityLogger(max_events=100)
|
|
732
|
+
server = EmulatedLifxServer(
|
|
733
|
+
[], device_manager, "127.0.0.1", 56700, activity_observer=activity_logger
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
# Add an event
|
|
737
|
+
event = PacketEvent(
|
|
738
|
+
timestamp="12:34:56",
|
|
739
|
+
direction="rx",
|
|
740
|
+
packet_type=2,
|
|
741
|
+
packet_name="GetService",
|
|
742
|
+
target=None,
|
|
743
|
+
device=None,
|
|
744
|
+
addr=("192.168.1.100", 56700),
|
|
745
|
+
)
|
|
746
|
+
activity_logger.on_packet_received(event)
|
|
747
|
+
|
|
748
|
+
activity = server.get_recent_activity()
|
|
749
|
+
assert len(activity) == 1
|
|
750
|
+
assert activity[0]["packet_name"] == "GetService"
|
|
751
|
+
|
|
752
|
+
def test_get_recent_activity_without_support(self):
|
|
753
|
+
"""Test get_recent_activity() returns empty list with no support."""
|
|
754
|
+
device_manager = DeviceManager(DeviceRepository())
|
|
755
|
+
server = EmulatedLifxServer(
|
|
756
|
+
[], device_manager, "127.0.0.1", 56700, track_activity=False
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
activity = server.get_recent_activity()
|
|
760
|
+
assert activity == []
|
|
761
|
+
|
|
762
|
+
def test_get_recent_activity_with_custom_observer(self):
|
|
763
|
+
"""Test get_recent_activity() with WebSocketActivityObserver."""
|
|
764
|
+
from lifx_emulator.devices import ActivityLogger
|
|
765
|
+
|
|
766
|
+
# This simulates the WebSocketActivityObserver pattern
|
|
767
|
+
device_manager = DeviceManager(DeviceRepository())
|
|
768
|
+
inner_logger = ActivityLogger(max_events=50)
|
|
769
|
+
|
|
770
|
+
# Create a mock observer that wraps the inner logger
|
|
771
|
+
class CustomObserver:
|
|
772
|
+
def __init__(self, inner):
|
|
773
|
+
self._inner = inner
|
|
774
|
+
|
|
775
|
+
def get_recent_activity(self):
|
|
776
|
+
return self._inner.get_recent_activity()
|
|
777
|
+
|
|
778
|
+
def on_packet_received(self, event):
|
|
779
|
+
self._inner.on_packet_received(event)
|
|
780
|
+
|
|
781
|
+
def on_packet_sent(self, event):
|
|
782
|
+
self._inner.on_packet_sent(event)
|
|
783
|
+
|
|
784
|
+
custom_observer = CustomObserver(inner_logger)
|
|
785
|
+
server = EmulatedLifxServer(
|
|
786
|
+
[], device_manager, "127.0.0.1", 56700, activity_observer=custom_observer
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
stats = server.get_stats()
|
|
790
|
+
assert stats["activity_enabled"] is True
|
|
791
|
+
|
|
792
|
+
activity = server.get_recent_activity()
|
|
793
|
+
assert activity == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/observers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/persistence.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/state_restorer.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/state_serializer.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/builder.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/default_config.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/factory.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/firmware_config.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/device_handlers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/light_handlers.py
RENAMED
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/registry.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/tile_handlers.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/generator.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/generator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/protocol_types.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/serializer.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/repositories/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/__init__.py
RENAMED
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.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
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device_handlers_extended.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.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
|