lifx-emulator 1.0.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/__init__.py +31 -0
- lifx_emulator/__main__.py +607 -0
- lifx_emulator/api.py +1825 -0
- lifx_emulator/async_storage.py +308 -0
- lifx_emulator/constants.py +33 -0
- lifx_emulator/device.py +750 -0
- lifx_emulator/device_states.py +114 -0
- lifx_emulator/factories.py +380 -0
- lifx_emulator/handlers/__init__.py +39 -0
- lifx_emulator/handlers/base.py +49 -0
- lifx_emulator/handlers/device_handlers.py +340 -0
- lifx_emulator/handlers/light_handlers.py +372 -0
- lifx_emulator/handlers/multizone_handlers.py +249 -0
- lifx_emulator/handlers/registry.py +110 -0
- lifx_emulator/handlers/tile_handlers.py +309 -0
- lifx_emulator/observers.py +139 -0
- lifx_emulator/products/__init__.py +28 -0
- lifx_emulator/products/generator.py +771 -0
- lifx_emulator/products/registry.py +1446 -0
- lifx_emulator/products/specs.py +242 -0
- lifx_emulator/products/specs.yml +327 -0
- lifx_emulator/protocol/__init__.py +1 -0
- lifx_emulator/protocol/base.py +334 -0
- lifx_emulator/protocol/const.py +8 -0
- lifx_emulator/protocol/generator.py +1371 -0
- lifx_emulator/protocol/header.py +159 -0
- lifx_emulator/protocol/packets.py +1351 -0
- lifx_emulator/protocol/protocol_types.py +844 -0
- lifx_emulator/protocol/serializer.py +379 -0
- lifx_emulator/scenario_manager.py +402 -0
- lifx_emulator/scenario_persistence.py +206 -0
- lifx_emulator/server.py +482 -0
- lifx_emulator/state_restorer.py +259 -0
- lifx_emulator/state_serializer.py +130 -0
- lifx_emulator/storage_protocol.py +100 -0
- lifx_emulator-1.0.0.dist-info/METADATA +445 -0
- lifx_emulator-1.0.0.dist-info/RECORD +40 -0
- lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
- lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
- lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
lifx_emulator/server.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""UDP server that emulates LIFX devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from lifx_emulator.constants import LIFX_HEADER_SIZE, LIFX_UDP_PORT
|
|
12
|
+
from lifx_emulator.device import EmulatedLifxDevice
|
|
13
|
+
from lifx_emulator.observers import (
|
|
14
|
+
ActivityLogger,
|
|
15
|
+
ActivityObserver,
|
|
16
|
+
NullObserver,
|
|
17
|
+
PacketEvent,
|
|
18
|
+
)
|
|
19
|
+
from lifx_emulator.protocol.header import LifxHeader
|
|
20
|
+
from lifx_emulator.protocol.packets import get_packet_class
|
|
21
|
+
from lifx_emulator.scenario_manager import HierarchicalScenarioManager
|
|
22
|
+
from lifx_emulator.scenario_persistence import ScenarioPersistence
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_packet_type_name(pkt_type: int) -> str:
|
|
28
|
+
"""Get human-readable name for packet type.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
pkt_type: Packet type number
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Packet class name or "Unknown"
|
|
35
|
+
"""
|
|
36
|
+
packet_class = get_packet_class(pkt_type)
|
|
37
|
+
if packet_class:
|
|
38
|
+
return packet_class.__qualname__ # e.g., "Device.GetService"
|
|
39
|
+
return f"Unknown({pkt_type})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format_packet_fields(packet: Any) -> str:
|
|
43
|
+
"""Format packet fields for logging, excluding reserved fields.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
packet: Packet instance to format
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Formatted string with field names and values
|
|
50
|
+
"""
|
|
51
|
+
if packet is None:
|
|
52
|
+
return "no payload"
|
|
53
|
+
|
|
54
|
+
fields = []
|
|
55
|
+
for field_item in packet._fields:
|
|
56
|
+
# Skip reserved fields (no name)
|
|
57
|
+
if "name" not in field_item:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Get field value
|
|
61
|
+
field_name = packet._protocol_to_python_name(field_item["name"])
|
|
62
|
+
value = getattr(packet, field_name, None)
|
|
63
|
+
|
|
64
|
+
# Format value based on type
|
|
65
|
+
if isinstance(value, bytes):
|
|
66
|
+
# For bytes, show hex if short, or length if long
|
|
67
|
+
if len(value) <= 8:
|
|
68
|
+
value_str = value.hex()
|
|
69
|
+
else:
|
|
70
|
+
value_str = f"<{len(value)} bytes>"
|
|
71
|
+
elif isinstance(value, list):
|
|
72
|
+
# For lists, show count and sample
|
|
73
|
+
if len(value) <= 3:
|
|
74
|
+
value_str = str(value)
|
|
75
|
+
else:
|
|
76
|
+
value_str = f"[{len(value)} items]"
|
|
77
|
+
elif hasattr(value, "__dict__"):
|
|
78
|
+
# For nested objects, show their string representation
|
|
79
|
+
value_str = str(value)
|
|
80
|
+
else:
|
|
81
|
+
value_str = str(value)
|
|
82
|
+
|
|
83
|
+
fields.append(f"{field_name}={value_str}")
|
|
84
|
+
|
|
85
|
+
return ", ".join(fields) if fields else "no fields"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EmulatedLifxServer:
|
|
89
|
+
"""UDP server that simulates LIFX devices"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
devices: list[EmulatedLifxDevice],
|
|
94
|
+
bind_address: str = "127.0.0.1",
|
|
95
|
+
port: int = LIFX_UDP_PORT,
|
|
96
|
+
track_activity: bool = True,
|
|
97
|
+
storage=None,
|
|
98
|
+
activity_observer: ActivityObserver | None = None,
|
|
99
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
100
|
+
persist_scenarios: bool = False,
|
|
101
|
+
):
|
|
102
|
+
self.devices = {dev.state.serial: dev for dev in devices}
|
|
103
|
+
self.bind_address = bind_address
|
|
104
|
+
self.port = port
|
|
105
|
+
self.transport = None
|
|
106
|
+
self.storage = storage
|
|
107
|
+
|
|
108
|
+
# Scenario persistence (optional)
|
|
109
|
+
self.scenario_persistence: ScenarioPersistence | None = None
|
|
110
|
+
if persist_scenarios:
|
|
111
|
+
self.scenario_persistence = ScenarioPersistence()
|
|
112
|
+
# Load scenarios from disk if available
|
|
113
|
+
if scenario_manager is None:
|
|
114
|
+
scenario_manager = self.scenario_persistence.load()
|
|
115
|
+
logger.info("Loaded scenarios from persistent storage")
|
|
116
|
+
|
|
117
|
+
# Scenario manager (shared across all devices for runtime updates)
|
|
118
|
+
self.scenario_manager = scenario_manager or HierarchicalScenarioManager()
|
|
119
|
+
|
|
120
|
+
# Activity observer - defaults to ActivityLogger if track_activity=True
|
|
121
|
+
if activity_observer is not None:
|
|
122
|
+
self.activity_observer = activity_observer
|
|
123
|
+
elif track_activity:
|
|
124
|
+
self.activity_observer = ActivityLogger(max_events=100)
|
|
125
|
+
else:
|
|
126
|
+
self.activity_observer = NullObserver()
|
|
127
|
+
|
|
128
|
+
# Statistics tracking
|
|
129
|
+
self.start_time = time.time()
|
|
130
|
+
self.packets_received = 0
|
|
131
|
+
self.packets_sent = 0
|
|
132
|
+
self.packets_received_by_type: dict[int, int] = defaultdict(int)
|
|
133
|
+
self.packets_sent_by_type: dict[int, int] = defaultdict(int)
|
|
134
|
+
self.error_count = 0
|
|
135
|
+
|
|
136
|
+
class LifxProtocol(asyncio.DatagramProtocol):
|
|
137
|
+
def __init__(self, server):
|
|
138
|
+
self.server = server
|
|
139
|
+
self.loop = None
|
|
140
|
+
|
|
141
|
+
def connection_made(self, transport):
|
|
142
|
+
self.transport = transport
|
|
143
|
+
self.server.transport = transport
|
|
144
|
+
# Cache event loop reference for optimized task scheduling
|
|
145
|
+
try:
|
|
146
|
+
self.loop = asyncio.get_running_loop()
|
|
147
|
+
except RuntimeError:
|
|
148
|
+
# No running loop yet (happens in tests or edge cases)
|
|
149
|
+
self.loop = None
|
|
150
|
+
logger.info(
|
|
151
|
+
"LIFX emulated server listening on %s:%s",
|
|
152
|
+
self.server.bind_address,
|
|
153
|
+
self.server.port,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def datagram_received(self, data, addr):
|
|
157
|
+
# Use direct loop scheduling for lower task creation overhead
|
|
158
|
+
# This is faster than asyncio.create_task() for high-frequency packets
|
|
159
|
+
if self.loop:
|
|
160
|
+
self.loop.call_soon(
|
|
161
|
+
self.loop.create_task, self.server.handle_packet(data, addr)
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
# Fallback for edge case where loop not yet cached
|
|
165
|
+
asyncio.create_task(self.server.handle_packet(data, addr))
|
|
166
|
+
|
|
167
|
+
async def _process_device_packet(
|
|
168
|
+
self,
|
|
169
|
+
device: EmulatedLifxDevice,
|
|
170
|
+
header: LifxHeader,
|
|
171
|
+
packet: Any | None,
|
|
172
|
+
addr: tuple[str, int],
|
|
173
|
+
):
|
|
174
|
+
"""Process packet for a single device and send responses.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
device: The device to process the packet
|
|
178
|
+
header: Parsed LIFX header
|
|
179
|
+
packet: Parsed packet payload (or None)
|
|
180
|
+
addr: Client address (host, port)
|
|
181
|
+
"""
|
|
182
|
+
responses = device.process_packet(header, packet)
|
|
183
|
+
|
|
184
|
+
# Get resolved scenario for response delays
|
|
185
|
+
scenario = device._get_resolved_scenario()
|
|
186
|
+
|
|
187
|
+
# Send responses with delay if configured
|
|
188
|
+
for resp_header, resp_packet in responses:
|
|
189
|
+
delay = scenario.response_delays.get(resp_header.pkt_type, 0.0)
|
|
190
|
+
if delay > 0:
|
|
191
|
+
await asyncio.sleep(delay)
|
|
192
|
+
|
|
193
|
+
# Pack the response packet
|
|
194
|
+
resp_payload = resp_packet.pack() if resp_packet else b""
|
|
195
|
+
response_data = resp_header.pack() + resp_payload
|
|
196
|
+
if self.transport:
|
|
197
|
+
self.transport.sendto(response_data, addr)
|
|
198
|
+
|
|
199
|
+
# Update statistics
|
|
200
|
+
self.packets_sent += 1
|
|
201
|
+
self.packets_sent_by_type[resp_header.pkt_type] += 1
|
|
202
|
+
|
|
203
|
+
# Log sent packet with details
|
|
204
|
+
resp_packet_name = _get_packet_type_name(resp_header.pkt_type)
|
|
205
|
+
resp_fields_str = _format_packet_fields(resp_packet)
|
|
206
|
+
logger.debug(
|
|
207
|
+
"→ TX %s to %s:%s (device=%s, seq=%s) [%s]",
|
|
208
|
+
resp_packet_name,
|
|
209
|
+
addr[0],
|
|
210
|
+
addr[1],
|
|
211
|
+
device.state.serial,
|
|
212
|
+
resp_header.sequence,
|
|
213
|
+
resp_fields_str,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Notify observer
|
|
217
|
+
self.activity_observer.on_packet_sent(
|
|
218
|
+
PacketEvent(
|
|
219
|
+
timestamp=time.time(),
|
|
220
|
+
direction="tx",
|
|
221
|
+
packet_type=resp_header.pkt_type,
|
|
222
|
+
packet_name=resp_packet_name,
|
|
223
|
+
addr=f"{addr[0]}:{addr[1]}",
|
|
224
|
+
device=device.state.serial,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def handle_packet(self, data: bytes, addr: tuple[str, int]):
|
|
229
|
+
"""Handle incoming UDP packet"""
|
|
230
|
+
try:
|
|
231
|
+
# Update statistics
|
|
232
|
+
self.packets_received += 1
|
|
233
|
+
|
|
234
|
+
if len(data) < LIFX_HEADER_SIZE:
|
|
235
|
+
logger.warning("Packet too short: %s bytes from %s", len(data), addr)
|
|
236
|
+
self.error_count += 1
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
# Parse header
|
|
240
|
+
header = LifxHeader.unpack(data)
|
|
241
|
+
payload = (
|
|
242
|
+
data[LIFX_HEADER_SIZE : header.size]
|
|
243
|
+
if header.size > LIFX_HEADER_SIZE
|
|
244
|
+
else b""
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Unpack payload into packet object
|
|
248
|
+
packet = None
|
|
249
|
+
packet_class = get_packet_class(header.pkt_type)
|
|
250
|
+
|
|
251
|
+
# Update packet type statistics
|
|
252
|
+
self.packets_received_by_type[header.pkt_type] += 1
|
|
253
|
+
|
|
254
|
+
if packet_class:
|
|
255
|
+
if payload:
|
|
256
|
+
try:
|
|
257
|
+
packet = packet_class.unpack(payload)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.warning(
|
|
260
|
+
"Failed to unpack %s (type %s) from %s:%s: %s",
|
|
261
|
+
_get_packet_type_name(header.pkt_type),
|
|
262
|
+
header.pkt_type,
|
|
263
|
+
addr[0],
|
|
264
|
+
addr[1],
|
|
265
|
+
e,
|
|
266
|
+
)
|
|
267
|
+
logger.debug(
|
|
268
|
+
"Raw payload (%s bytes): %s", len(payload), payload.hex()
|
|
269
|
+
)
|
|
270
|
+
return
|
|
271
|
+
# else: packet_class exists but no payload (valid for some packet types)
|
|
272
|
+
else:
|
|
273
|
+
# Unknown packet type - log it with raw payload
|
|
274
|
+
target_str = "broadcast" if header.tagged else header.target.hex()
|
|
275
|
+
logger.warning(
|
|
276
|
+
"← RX Unknown packet type %s from %s:%s (target=%s, seq=%s)",
|
|
277
|
+
header.pkt_type,
|
|
278
|
+
addr[0],
|
|
279
|
+
addr[1],
|
|
280
|
+
target_str,
|
|
281
|
+
header.sequence,
|
|
282
|
+
)
|
|
283
|
+
if payload:
|
|
284
|
+
logger.info(
|
|
285
|
+
"Unknown packet payload (%s bytes): %s",
|
|
286
|
+
len(payload),
|
|
287
|
+
payload.hex(),
|
|
288
|
+
)
|
|
289
|
+
# Continue processing - device might still want to respond or log it
|
|
290
|
+
|
|
291
|
+
# Log received packet with details
|
|
292
|
+
packet_name = _get_packet_type_name(header.pkt_type)
|
|
293
|
+
target_str = "broadcast" if header.tagged else header.target.hex()
|
|
294
|
+
fields_str = _format_packet_fields(packet)
|
|
295
|
+
logger.debug(
|
|
296
|
+
"← RX %s from %s:%s (target=%s, seq=%s) [%s]",
|
|
297
|
+
packet_name,
|
|
298
|
+
addr[0],
|
|
299
|
+
addr[1],
|
|
300
|
+
target_str,
|
|
301
|
+
header.sequence,
|
|
302
|
+
fields_str,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Notify observer
|
|
306
|
+
self.activity_observer.on_packet_received(
|
|
307
|
+
PacketEvent(
|
|
308
|
+
timestamp=time.time(),
|
|
309
|
+
direction="rx",
|
|
310
|
+
packet_type=header.pkt_type,
|
|
311
|
+
packet_name=packet_name,
|
|
312
|
+
addr=f"{addr[0]}:{addr[1]}",
|
|
313
|
+
target=target_str,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Determine target devices
|
|
318
|
+
target_devices = []
|
|
319
|
+
if header.tagged or header.target == b"\x00" * 8:
|
|
320
|
+
# Broadcast to all devices
|
|
321
|
+
target_devices = list(self.devices.values())
|
|
322
|
+
else:
|
|
323
|
+
# Specific device - convert target bytes to serial string
|
|
324
|
+
# Target is 8 bytes: 6-byte MAC + 2 null bytes
|
|
325
|
+
target_serial = header.target[:6].hex()
|
|
326
|
+
device = self.devices.get(target_serial)
|
|
327
|
+
if device:
|
|
328
|
+
target_devices = [device]
|
|
329
|
+
|
|
330
|
+
# Process packet for each target device
|
|
331
|
+
# Use parallel processing for broadcasts to improve scalability
|
|
332
|
+
if len(target_devices) > 1:
|
|
333
|
+
# Broadcast: process all devices concurrently (limited by GIL)
|
|
334
|
+
tasks = [
|
|
335
|
+
self._process_device_packet(device, header, packet, addr)
|
|
336
|
+
for device in target_devices
|
|
337
|
+
]
|
|
338
|
+
await asyncio.gather(*tasks)
|
|
339
|
+
elif target_devices:
|
|
340
|
+
# Single device: process directly without task overhead
|
|
341
|
+
await self._process_device_packet(
|
|
342
|
+
target_devices[0], header, packet, addr
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
self.error_count += 1
|
|
347
|
+
logger.error("Error handling packet from %s: %s", addr, e, exc_info=True)
|
|
348
|
+
|
|
349
|
+
def add_device(self, device: EmulatedLifxDevice) -> bool:
|
|
350
|
+
"""Add a device to the server.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
device: The device to add
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
True if added, False if device with same serial already exists
|
|
357
|
+
"""
|
|
358
|
+
serial = device.state.serial
|
|
359
|
+
if serial in self.devices:
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
# If device is using HierarchicalScenarioManager, share the server's manager
|
|
363
|
+
if isinstance(device.scenario_manager, HierarchicalScenarioManager):
|
|
364
|
+
device.scenario_manager = self.scenario_manager
|
|
365
|
+
device.invalidate_scenario_cache()
|
|
366
|
+
|
|
367
|
+
self.devices[serial] = device
|
|
368
|
+
logger.info("Added device: %s (product=%s)", serial, device.state.product)
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
def remove_device(self, serial: str) -> bool:
|
|
372
|
+
"""Remove a device from the server.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
serial: Serial number of device to remove (12 hex chars)
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
True if removed, False if device not found
|
|
379
|
+
"""
|
|
380
|
+
if serial not in self.devices:
|
|
381
|
+
return False
|
|
382
|
+
self.devices.pop(serial)
|
|
383
|
+
logger.info("Removed device: %s", serial)
|
|
384
|
+
|
|
385
|
+
# Delete persistent storage if enabled
|
|
386
|
+
if self.storage:
|
|
387
|
+
self.storage.delete_device_state(serial)
|
|
388
|
+
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
def remove_all_devices(self, delete_storage: bool = False) -> int:
|
|
392
|
+
"""Remove all devices from the server.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
delete_storage: If True, also delete persistent storage files
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Number of devices removed
|
|
399
|
+
"""
|
|
400
|
+
device_count = len(self.devices)
|
|
401
|
+
|
|
402
|
+
# Clear devices dict
|
|
403
|
+
self.devices.clear()
|
|
404
|
+
logger.info("Removed all %s device(s) from server", device_count)
|
|
405
|
+
|
|
406
|
+
# Delete persistent storage if requested
|
|
407
|
+
if delete_storage and self.storage:
|
|
408
|
+
deleted = self.storage.delete_all_device_states()
|
|
409
|
+
logger.info("Deleted %s device state(s) from persistent storage", deleted)
|
|
410
|
+
|
|
411
|
+
return device_count
|
|
412
|
+
|
|
413
|
+
def get_device(self, serial: str) -> EmulatedLifxDevice | None:
|
|
414
|
+
"""Get a device by serial number.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
serial: Serial number (12 hex chars)
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Device if found, None otherwise
|
|
421
|
+
"""
|
|
422
|
+
return self.devices.get(serial)
|
|
423
|
+
|
|
424
|
+
def get_all_devices(self) -> list[EmulatedLifxDevice]:
|
|
425
|
+
"""Get all devices.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
List of all devices
|
|
429
|
+
"""
|
|
430
|
+
return list(self.devices.values())
|
|
431
|
+
|
|
432
|
+
def get_stats(self) -> dict[str, Any]:
|
|
433
|
+
"""Get server statistics.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Dictionary with statistics
|
|
437
|
+
"""
|
|
438
|
+
uptime = time.time() - self.start_time
|
|
439
|
+
return {
|
|
440
|
+
"uptime_seconds": uptime,
|
|
441
|
+
"start_time": self.start_time,
|
|
442
|
+
"device_count": len(self.devices),
|
|
443
|
+
"packets_received": self.packets_received,
|
|
444
|
+
"packets_sent": self.packets_sent,
|
|
445
|
+
"packets_received_by_type": dict(self.packets_received_by_type),
|
|
446
|
+
"packets_sent_by_type": dict(self.packets_sent_by_type),
|
|
447
|
+
"error_count": self.error_count,
|
|
448
|
+
"activity_enabled": isinstance(self.activity_observer, ActivityLogger),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
def get_recent_activity(self) -> list[dict[str, Any]]:
|
|
452
|
+
"""Get recent activity events.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
List of activity event dictionaries, or empty list if observer
|
|
456
|
+
doesn't support activity tracking
|
|
457
|
+
"""
|
|
458
|
+
if isinstance(self.activity_observer, ActivityLogger):
|
|
459
|
+
return self.activity_observer.get_recent_activity()
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
async def start(self):
|
|
463
|
+
"""Start the server"""
|
|
464
|
+
loop = asyncio.get_running_loop()
|
|
465
|
+
self.transport, _ = await loop.create_datagram_endpoint(
|
|
466
|
+
lambda: self.LifxProtocol(self), local_addr=(self.bind_address, self.port)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
async def stop(self):
|
|
470
|
+
"""Stop the server"""
|
|
471
|
+
if self.transport:
|
|
472
|
+
self.transport.close()
|
|
473
|
+
|
|
474
|
+
async def __aenter__(self):
|
|
475
|
+
"""Async context manager entry"""
|
|
476
|
+
await self.start()
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
480
|
+
"""Async context manager exit"""
|
|
481
|
+
await self.stop()
|
|
482
|
+
return False
|