lifx-emulator-core 3.4.0__py3-none-any.whl → 3.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
lifx_emulator/server.py CHANGED
@@ -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):
@@ -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,9 +1,9 @@
1
1
  lifx_emulator/__init__.py,sha256=SSnQg0RiCaID7DhHOGZoVIDQ3_8Lyt0J9cA_2StF63s,824
2
2
  lifx_emulator/constants.py,sha256=DFZkUsdewE-x_3MgO28tMGkjUCWPeYc3xLj_EXViGOw,1032
3
- lifx_emulator/server.py,sha256=x-RDfxwmzUfSSROfBzVm2LcGK68sVIM4o7IkcgzkSfI,18363
4
- lifx_emulator/devices/__init__.py,sha256=QlBTPnFErJcSKLvGyeDwemh7xcpjYvB_L5siKsjr3s8,1089
3
+ lifx_emulator/server.py,sha256=UsecEjdxTvJrM3KXc1S8_YIkQIBX9Yi8WnBnja67Og8,18395
4
+ lifx_emulator/devices/__init__.py,sha256=Cfr2L1XqtZcxSTaSFcq0TdQnAjjT4PoGiiMxge3eMI0,1210
5
5
  lifx_emulator/devices/device.py,sha256=w7Y9VYux3cyGus9mFmruFn6WYtoD7zwhzCL2xrICY-Q,17180
6
- lifx_emulator/devices/manager.py,sha256=XDrT82um5sgNpNihLj5RsNvHqdVI1bK9YY2eBzWIcf0,8162
6
+ lifx_emulator/devices/manager.py,sha256=5mc1f8sB_vVYvxwmixFfpbQHVK7VPtBJIE7ChjVCEGk,9961
7
7
  lifx_emulator/devices/observers.py,sha256=-KnUgFcKdhlNo7CNVstP-u0wU2W0JAGg055ZPV15Sj0,3874
8
8
  lifx_emulator/devices/persistence.py,sha256=9Mhj46-xrweOmyzjORCi2jKIwa8XJWpQ5CgaKcw6U98,10513
9
9
  lifx_emulator/devices/state_restorer.py,sha256=eDsRSW-2RviP_0Qlk2DHqMaB-zhV0X1cNQECv2lD1qc,9809
@@ -39,9 +39,9 @@ lifx_emulator/repositories/__init__.py,sha256=x-ncM6T_Q7jNrwhK4a1uAyMrTGHHGeUzPS
39
39
  lifx_emulator/repositories/device_repository.py,sha256=KsXVg2sg7PGSTsK_PvDYeHHwEPM9Qx2ZZF_ORncBrYQ,3929
40
40
  lifx_emulator/repositories/storage_backend.py,sha256=wEgjhnBvAxl6aO1ZGL3ou0dW9P2hBPnK8jEE03sOlL4,3264
41
41
  lifx_emulator/scenarios/__init__.py,sha256=CGjudoWvyysvFj2xej11N2cr3mYROGtRb9zVHcOHGrQ,665
42
- lifx_emulator/scenarios/manager.py,sha256=1esxRdz74UynNk1wb86MGZ2ZFAuMzByuu74nRe3D-Og,11163
43
- lifx_emulator/scenarios/models.py,sha256=1cX399JcTYVo29-8Rc4BwYPRty7sMR4fcn0njGfspZg,4504
42
+ lifx_emulator/scenarios/manager.py,sha256=pp_XHkEbVofwgng5pYAV8lL5eQM6tubte5Dq4hAlOCA,11377
43
+ lifx_emulator/scenarios/models.py,sha256=1OfQWIr3yEwI5IUTBPewxCvagNNhoLaoqfEp3fZJTtQ,4503
44
44
  lifx_emulator/scenarios/persistence.py,sha256=3vjtPNFYfag38tUxuqxkGpWhQ7uBitc1rLroSAuw9N8,8881
45
- lifx_emulator_core-3.4.0.dist-info/METADATA,sha256=FvDJG20jN-BGRJ03z6HG69c7eJ5E27zhV6EuVIB-qsA,3217
46
- lifx_emulator_core-3.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
- lifx_emulator_core-3.4.0.dist-info/RECORD,,
45
+ lifx_emulator_core-3.5.0.dist-info/METADATA,sha256=BEWJae_4f4ETk31sHpK_dEruIRohKUs5iD9pqJD6VsE,3217
46
+ lifx_emulator_core-3.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
+ lifx_emulator_core-3.5.0.dist-info/RECORD,,