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.
Files changed (74) hide show
  1. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/.gitignore +0 -1
  2. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/CHANGELOG.md +16 -0
  3. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/PKG-INFO +1 -1
  4. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/pyproject.toml +1 -1
  5. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/__init__.py +8 -1
  6. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/manager.py +44 -1
  7. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/manager.py +8 -7
  8. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/models.py +1 -1
  9. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/server.py +4 -3
  10. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device_manager.py +123 -0
  11. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_scenario_manager.py +1 -1
  12. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_scenario_persistence.py +1 -1
  13. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_server.py +98 -0
  14. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/README.md +0 -0
  15. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/__init__.py +0 -0
  16. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/constants.py +0 -0
  17. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/device.py +0 -0
  18. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/observers.py +0 -0
  19. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/persistence.py +0 -0
  20. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
  21. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
  22. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/devices/states.py +0 -0
  23. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/__init__.py +0 -0
  24. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/builder.py +0 -0
  25. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/default_config.py +0 -0
  26. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/factory.py +0 -0
  27. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
  28. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
  29. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/__init__.py +0 -0
  30. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/base.py +0 -0
  31. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
  32. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
  33. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/multizone_handlers.py +0 -0
  34. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/registry.py +0 -0
  35. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/handlers/tile_handlers.py +0 -0
  36. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/__init__.py +0 -0
  37. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/generator.py +0 -0
  38. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/registry.py +0 -0
  39. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/specs.py +0 -0
  40. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/products/specs.yml +0 -0
  41. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/__init__.py +0 -0
  42. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/base.py +0 -0
  43. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/const.py +0 -0
  44. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/generator.py +0 -0
  45. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/header.py +0 -0
  46. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/packets.py +0 -0
  47. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
  48. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/protocol/serializer.py +0 -0
  49. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/repositories/__init__.py +0 -0
  50. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
  51. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
  52. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
  53. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
  54. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/conftest.py +0 -0
  55. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_async_storage.py +0 -0
  56. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_backwards_compatibility.py +0 -0
  57. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device.py +0 -0
  58. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device_edge_cases.py +0 -0
  59. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_device_handlers_extended.py +0 -0
  60. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_handler_registry.py +0 -0
  61. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_integration.py +0 -0
  62. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_light_handlers_extended.py +0 -0
  63. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_multizone_handlers_extended.py +0 -0
  64. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_observers.py +0 -0
  65. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_partial_responses.py +0 -0
  66. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_products_generator.py +0 -0
  67. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_products_specs.py +0 -0
  68. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_protocol_generator.py +0 -0
  69. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_protocol_types_coverage.py +0 -0
  70. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_repositories.py +0 -0
  71. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_serializer.py +0 -0
  72. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_state_restorer.py +0 -0
  73. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_switch_devices.py +0 -0
  74. {lifx_emulator_core-3.4.0 → lifx_emulator_core-3.5.0}/tests/test_tile_handlers_extended.py +0 -0
@@ -12,7 +12,6 @@ dist/
12
12
  downloads/
13
13
  eggs/
14
14
  .eggs/
15
- lib/
16
15
  lib64/
17
16
  parts/
18
17
  sdist/
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator-core
3
- Version: 3.4.0
3
+ Version: 3.5.0
4
4
  Summary: Core LIFX Emulator library for testing LIFX LAN protocol libraries
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-emulator-core"
3
- version = "3.4.0"
3
+ version = "3.5.0"
4
4
  description = "Core LIFX Emulator library for testing LIFX LAN protocol libraries"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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 DeviceManager, IDeviceManager
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__(self, device_repository: IDeviceRepository):
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:
@@ -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=False
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 empty)."""
105
+ """Clear global scenario (reset to default with send_unhandled=True)."""
105
106
  self.global_scenario = ScenarioConfig(
106
- firmware_version=None, send_unhandled=False
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 empty config
188
- merged = ScenarioConfig(firmware_version=None, send_unhandled=False)
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
- if config.send_unhandled:
223
- merged.send_unhandled = True
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
- False, description="Send unhandled message responses for unknown packet types"
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": isinstance(self.activity_observer, ActivityLogger),
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
- if isinstance(self.activity_observer, ActivityLogger):
496
- return self.activity_observer.get_recent_activity()
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 False
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": False,
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 == []