lifx-emulator 3.1.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.
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
- lifx_emulator-4.2.0.dist-info/RECORD +43 -0
- lifx_emulator_app/__main__.py +693 -137
- lifx_emulator_app/api/__init__.py +0 -4
- lifx_emulator_app/api/app.py +122 -16
- lifx_emulator_app/api/models.py +32 -1
- lifx_emulator_app/api/routers/__init__.py +5 -1
- lifx_emulator_app/api/routers/devices.py +64 -10
- lifx_emulator_app/api/routers/products.py +42 -0
- lifx_emulator_app/api/routers/scenarios.py +55 -52
- lifx_emulator_app/api/routers/websocket.py +70 -0
- lifx_emulator_app/api/services/__init__.py +21 -4
- lifx_emulator_app/api/services/device_service.py +188 -1
- lifx_emulator_app/api/services/event_bridge.py +234 -0
- lifx_emulator_app/api/services/scenario_service.py +153 -0
- lifx_emulator_app/api/services/websocket_manager.py +326 -0
- lifx_emulator_app/api/static/_app/env.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
- lifx_emulator_app/api/static/_app/version.json +1 -0
- lifx_emulator_app/api/static/index.html +38 -0
- lifx_emulator_app/api/static/robots.txt +3 -0
- lifx_emulator_app/config.py +316 -0
- lifx_emulator-3.1.0.dist-info/RECORD +0 -19
- lifx_emulator_app/api/static/dashboard.js +0 -588
- lifx_emulator_app/api/templates/dashboard.html +0 -357
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-3.1.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)
|