lifx-emulator 4.0.0__py3-none-any.whl → 4.2.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.
Files changed (42) hide show
  1. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
  2. lifx_emulator-4.2.0.dist-info/RECORD +43 -0
  3. lifx_emulator_app/__main__.py +35 -7
  4. lifx_emulator_app/api/__init__.py +0 -4
  5. lifx_emulator_app/api/app.py +122 -16
  6. lifx_emulator_app/api/models.py +32 -1
  7. lifx_emulator_app/api/routers/__init__.py +5 -1
  8. lifx_emulator_app/api/routers/devices.py +64 -10
  9. lifx_emulator_app/api/routers/products.py +42 -0
  10. lifx_emulator_app/api/routers/scenarios.py +55 -52
  11. lifx_emulator_app/api/routers/websocket.py +70 -0
  12. lifx_emulator_app/api/services/__init__.py +21 -4
  13. lifx_emulator_app/api/services/device_service.py +188 -1
  14. lifx_emulator_app/api/services/event_bridge.py +234 -0
  15. lifx_emulator_app/api/services/scenario_service.py +153 -0
  16. lifx_emulator_app/api/services/websocket_manager.py +326 -0
  17. lifx_emulator_app/api/static/_app/env.js +1 -0
  18. lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
  19. lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
  20. lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
  21. lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
  22. lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
  23. lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
  24. lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
  25. lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
  26. lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
  27. lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
  28. lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
  29. lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
  30. lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
  31. lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
  32. lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
  33. lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
  34. lifx_emulator_app/api/static/_app/version.json +1 -0
  35. lifx_emulator_app/api/static/index.html +38 -0
  36. lifx_emulator_app/api/static/robots.txt +3 -0
  37. lifx_emulator_app/config.py +2 -0
  38. lifx_emulator-4.0.0.dist-info/RECORD +0 -20
  39. lifx_emulator_app/api/static/dashboard.js +0 -588
  40. lifx_emulator_app/api/templates/dashboard.html +0 -357
  41. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
  42. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,234 @@
1
+ """Event bridge for connecting core library callbacks to WebSocket broadcasts.
2
+
3
+ This module provides functions to wire up synchronous device lifecycle callbacks
4
+ to asynchronous WebSocket broadcasts, bridging the gap between the core library
5
+ (which has no async dependencies) and the FastAPI application layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from typing import TYPE_CHECKING
13
+
14
+ from lifx_emulator.devices import PacketEvent
15
+
16
+ if TYPE_CHECKING:
17
+ from lifx_emulator.devices import (
18
+ ActivityLogger,
19
+ ActivityObserver,
20
+ EmulatedLifxDevice,
21
+ IDeviceManager,
22
+ )
23
+ from lifx_emulator.server import EmulatedLifxServer
24
+
25
+ from lifx_emulator_app.api.services.websocket_manager import WebSocketManager
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _schedule_async(coro) -> None:
31
+ """Schedule an async coroutine from a sync context.
32
+
33
+ Args:
34
+ coro: The coroutine to schedule
35
+ """
36
+ try:
37
+ loop = asyncio.get_running_loop()
38
+ loop.create_task(coro)
39
+ except RuntimeError:
40
+ logger.warning("No running event loop to schedule async task")
41
+
42
+
43
+ def wire_device_events(
44
+ device_manager: IDeviceManager, ws_manager: WebSocketManager
45
+ ) -> None:
46
+ """Wire device lifecycle callbacks to WebSocket broadcasts.
47
+
48
+ This sets up the DeviceManager callbacks to broadcast events to
49
+ connected WebSocket clients when devices are added or removed.
50
+
51
+ Args:
52
+ device_manager: The DeviceManager to wire callbacks to (must be
53
+ a DeviceManager instance that supports callbacks)
54
+ ws_manager: The WebSocketManager to broadcast events through
55
+ """
56
+ from lifx_emulator.devices import DeviceManager
57
+
58
+ from lifx_emulator_app.api.mappers.device_mapper import DeviceMapper
59
+
60
+ # Only DeviceManager (not all IDeviceManager implementations) supports callbacks
61
+ if not isinstance(device_manager, DeviceManager):
62
+ logger.warning(
63
+ "Device manager is not a DeviceManager instance, skipping event wiring"
64
+ )
65
+ return
66
+
67
+ def on_device_added(device: EmulatedLifxDevice) -> None:
68
+ """Callback invoked when a device is added."""
69
+ device_info = DeviceMapper.to_device_info(device)
70
+ _schedule_async(ws_manager.broadcast_device_added(device_info.model_dump()))
71
+ logger.debug("Scheduled device_added broadcast for %s", device.state.serial)
72
+
73
+ def on_device_removed(serial: str) -> None:
74
+ """Callback invoked when a device is removed."""
75
+ _schedule_async(ws_manager.broadcast_device_removed(serial))
76
+ logger.debug("Scheduled device_removed broadcast for %s", serial)
77
+
78
+ device_manager.on_device_added = on_device_added
79
+ device_manager.on_device_removed = on_device_removed
80
+
81
+ logger.info("Device event callbacks wired to WebSocket manager")
82
+
83
+
84
+ class WebSocketActivityObserver:
85
+ """ActivityObserver implementation that broadcasts events via WebSocket.
86
+
87
+ This observer bridges the synchronous ActivityObserver protocol to
88
+ asynchronous WebSocket broadcasts, allowing real-time activity updates
89
+ to connected clients.
90
+
91
+ Also wraps an optional inner observer (typically ActivityLogger) to
92
+ maintain the activity log while adding WebSocket broadcasting.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ ws_manager: WebSocketManager,
98
+ inner_observer: ActivityObserver | None = None,
99
+ ) -> None:
100
+ """Initialize the WebSocket activity observer.
101
+
102
+ Args:
103
+ ws_manager: The WebSocketManager to broadcast events through
104
+ inner_observer: Optional inner observer to delegate to (for logging).
105
+ If it has get_recent_activity(), that will be used.
106
+ """
107
+ from lifx_emulator.devices import ActivityLogger
108
+
109
+ self._ws_manager = ws_manager
110
+ # Use provided observer or create a new ActivityLogger
111
+ self._inner: ActivityLogger | ActivityObserver = (
112
+ inner_observer
113
+ if inner_observer is not None
114
+ else ActivityLogger(max_events=100)
115
+ )
116
+
117
+ def on_packet_received(self, event: PacketEvent) -> None:
118
+ """Handle packet received event.
119
+
120
+ Args:
121
+ event: The packet event with direction='rx'
122
+ """
123
+ # Delegate to inner observer for logging
124
+ self._inner.on_packet_received(event)
125
+
126
+ # Broadcast to WebSocket clients
127
+ _schedule_async(
128
+ self._ws_manager.broadcast_activity(
129
+ {
130
+ "timestamp": event.timestamp,
131
+ "direction": "rx",
132
+ "packet_type": event.packet_type,
133
+ "packet_name": event.packet_name,
134
+ "target": event.target,
135
+ "addr": event.addr,
136
+ }
137
+ )
138
+ )
139
+
140
+ def on_packet_sent(self, event: PacketEvent) -> None:
141
+ """Handle packet sent event.
142
+
143
+ Args:
144
+ event: The packet event with direction='tx'
145
+ """
146
+ # Delegate to inner observer for logging
147
+ self._inner.on_packet_sent(event)
148
+
149
+ # Broadcast to WebSocket clients
150
+ _schedule_async(
151
+ self._ws_manager.broadcast_activity(
152
+ {
153
+ "timestamp": event.timestamp,
154
+ "direction": "tx",
155
+ "packet_type": event.packet_type,
156
+ "packet_name": event.packet_name,
157
+ "device": event.device,
158
+ "addr": event.addr,
159
+ }
160
+ )
161
+ )
162
+
163
+ def get_recent_activity(self) -> list[dict]:
164
+ """Get recent activity from the inner logger.
165
+
166
+ Returns:
167
+ List of recent activity events, or empty list if inner observer
168
+ doesn't support activity tracking
169
+ """
170
+ get_activity = getattr(self._inner, "get_recent_activity", None)
171
+ if get_activity is not None:
172
+ return get_activity()
173
+ return []
174
+
175
+
176
+ class StatsBroadcaster:
177
+ """Background task that broadcasts server stats periodically.
178
+
179
+ Broadcasts stats to WebSocket clients at a configurable interval
180
+ (default 1 second).
181
+ """
182
+
183
+ def __init__(
184
+ self,
185
+ server: EmulatedLifxServer,
186
+ ws_manager: WebSocketManager,
187
+ interval: float = 1.0,
188
+ ) -> None:
189
+ """Initialize the stats broadcaster.
190
+
191
+ Args:
192
+ server: The LIFX emulator server to get stats from
193
+ ws_manager: The WebSocketManager to broadcast through
194
+ interval: Broadcast interval in seconds (default 1.0)
195
+ """
196
+ self._server = server
197
+ self._ws_manager = ws_manager
198
+ self._interval = interval
199
+ self._task: asyncio.Task | None = None
200
+ self._running = False
201
+
202
+ async def _broadcast_loop(self) -> None:
203
+ """Background loop that broadcasts stats at regular intervals."""
204
+ while self._running:
205
+ try:
206
+ stats = self._server.get_stats()
207
+ await self._ws_manager.broadcast_stats(stats)
208
+ except Exception:
209
+ logger.exception("Error broadcasting stats")
210
+
211
+ await asyncio.sleep(self._interval)
212
+
213
+ def start(self) -> None:
214
+ """Start the stats broadcast background task."""
215
+ if self._task is not None:
216
+ return
217
+
218
+ self._running = True
219
+ self._task = asyncio.create_task(self._broadcast_loop())
220
+ logger.info("Stats broadcaster started (interval=%.1fs)", self._interval)
221
+
222
+ async def stop(self) -> None:
223
+ """Stop the stats broadcast background task."""
224
+ if self._task is None:
225
+ return
226
+
227
+ self._running = False
228
+ self._task.cancel()
229
+ try:
230
+ await self._task
231
+ except asyncio.CancelledError:
232
+ pass
233
+ self._task = None
234
+ logger.info("Stats broadcaster stopped")
@@ -0,0 +1,153 @@
1
+ """Scenario management business logic service.
2
+
3
+ Separates API handlers from server operations, providing a clean service layer
4
+ for scenario CRUD operations across all scope levels (global, device, type,
5
+ location, group).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING, Literal
12
+
13
+ if TYPE_CHECKING:
14
+ from lifx_emulator.scenarios import ScenarioConfig
15
+ from lifx_emulator.server import EmulatedLifxServer
16
+
17
+ from lifx_emulator_app.api.services.websocket_manager import WebSocketManager
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ Scope = Literal["device", "type", "location", "group"]
22
+
23
+ _SCOPE_METHODS: dict[Scope, tuple[str, str, str]] = {
24
+ "device": ("get_device_scenario", "set_device_scenario", "delete_device_scenario"),
25
+ "type": ("get_type_scenario", "set_type_scenario", "delete_type_scenario"),
26
+ "location": (
27
+ "get_location_scenario",
28
+ "set_location_scenario",
29
+ "delete_location_scenario",
30
+ ),
31
+ "group": ("get_group_scenario", "set_group_scenario", "delete_group_scenario"),
32
+ }
33
+
34
+
35
+ class ScenarioNotFoundError(Exception):
36
+ """Raised when a scenario is not set for the given scope and identifier."""
37
+
38
+ def __init__(self, scope: str, identifier: str):
39
+ super().__init__(f"No scenario set for {scope} {identifier}")
40
+ self.scope = scope
41
+ self.identifier = identifier
42
+
43
+
44
+ class InvalidDeviceSerialError(Exception):
45
+ """Raised when a device serial is not a valid 12-character hex string."""
46
+
47
+ def __init__(self, serial: str):
48
+ super().__init__(f"Invalid device serial format: {serial}.")
49
+ self.serial = serial
50
+
51
+
52
+ class ScenarioService:
53
+ """Service for managing scenario configurations across all scope levels.
54
+
55
+ Wraps the HierarchicalScenarioManager and handles cache invalidation,
56
+ persistence, and WebSocket broadcasts after each mutation.
57
+ """
58
+
59
+ def __init__(
60
+ self, server: EmulatedLifxServer, ws_manager: WebSocketManager | None = None
61
+ ):
62
+ self.server = server
63
+ self._ws_manager = ws_manager
64
+
65
+ async def _persist(self) -> None:
66
+ """Invalidate device scenario caches and persist to storage."""
67
+ self.server.invalidate_all_scenario_caches()
68
+ if self.server.scenario_persistence:
69
+ await self.server.scenario_persistence.save(self.server.scenario_manager)
70
+
71
+ async def _broadcast_change(
72
+ self, scope: str, identifier: str | None, config: ScenarioConfig | None
73
+ ) -> None:
74
+ """Broadcast scenario change event via WebSocket.
75
+
76
+ Args:
77
+ scope: The scope level (global, device, type, location, group)
78
+ identifier: The scope identifier (serial, type, etc.) or None for global
79
+ config: The new scenario config, or None if deleted
80
+ """
81
+ if self._ws_manager is None:
82
+ return
83
+
84
+ config_dict = config.model_dump() if config is not None else None
85
+ await self._ws_manager.broadcast_scenario_changed(
86
+ scope, identifier, config_dict
87
+ )
88
+
89
+ def get_global_scenario(self) -> ScenarioConfig:
90
+ """Return the global scenario configuration."""
91
+ return self.server.scenario_manager.get_global_scenario()
92
+
93
+ async def set_global_scenario(self, config: ScenarioConfig) -> None:
94
+ """Set the global scenario and persist."""
95
+ self.server.scenario_manager.set_global_scenario(config)
96
+ await self._persist()
97
+ await self._broadcast_change("global", None, config)
98
+ logger.info("Set global scenario")
99
+
100
+ async def clear_global_scenario(self) -> None:
101
+ """Clear the global scenario and persist."""
102
+ self.server.scenario_manager.clear_global_scenario()
103
+ await self._persist()
104
+ await self._broadcast_change("global", None, None)
105
+ logger.info("Cleared global scenario")
106
+
107
+ def get_scope_scenario(self, scope: Scope, identifier: str) -> ScenarioConfig:
108
+ """Return the scenario for a given scope and identifier.
109
+
110
+ Raises:
111
+ ScenarioNotFoundError: If no scenario is set.
112
+ """
113
+ getter, _, _ = _SCOPE_METHODS[scope]
114
+ config = getattr(self.server.scenario_manager, getter)(identifier)
115
+ if config is None:
116
+ raise ScenarioNotFoundError(scope, identifier)
117
+ return config
118
+
119
+ async def set_scope_scenario(
120
+ self, scope: Scope, identifier: str, config: ScenarioConfig
121
+ ) -> None:
122
+ """Set a scenario for a given scope and identifier, then persist.
123
+
124
+ Raises:
125
+ InvalidDeviceSerialError: If scope is "device" and the serial
126
+ is not a valid 12-character hex string.
127
+ """
128
+ if scope == "device" and not self._is_valid_serial(identifier):
129
+ raise InvalidDeviceSerialError(identifier)
130
+
131
+ _, setter, _ = _SCOPE_METHODS[scope]
132
+ getattr(self.server.scenario_manager, setter)(identifier, config)
133
+ await self._persist()
134
+ await self._broadcast_change(scope, identifier, config)
135
+ logger.info("Set %s scenario for %s", scope, identifier)
136
+
137
+ async def delete_scope_scenario(self, scope: Scope, identifier: str) -> None:
138
+ """Delete a scenario for a given scope and identifier, then persist.
139
+
140
+ Raises:
141
+ ScenarioNotFoundError: If no scenario is set.
142
+ """
143
+ _, _, deleter = _SCOPE_METHODS[scope]
144
+ if not getattr(self.server.scenario_manager, deleter)(identifier):
145
+ raise ScenarioNotFoundError(scope, identifier)
146
+ await self._persist()
147
+ await self._broadcast_change(scope, identifier, None)
148
+ logger.info("Deleted %s scenario for %s", scope, identifier)
149
+
150
+ @staticmethod
151
+ def _is_valid_serial(serial: str) -> bool:
152
+ """Check that serial is a 12-character hex string."""
153
+ return len(serial) == 12 and all(c in "0123456789abcdefABCDEF" for c in serial)