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.
Files changed (40) hide show
  1. lifx_emulator/__init__.py +31 -0
  2. lifx_emulator/__main__.py +607 -0
  3. lifx_emulator/api.py +1825 -0
  4. lifx_emulator/async_storage.py +308 -0
  5. lifx_emulator/constants.py +33 -0
  6. lifx_emulator/device.py +750 -0
  7. lifx_emulator/device_states.py +114 -0
  8. lifx_emulator/factories.py +380 -0
  9. lifx_emulator/handlers/__init__.py +39 -0
  10. lifx_emulator/handlers/base.py +49 -0
  11. lifx_emulator/handlers/device_handlers.py +340 -0
  12. lifx_emulator/handlers/light_handlers.py +372 -0
  13. lifx_emulator/handlers/multizone_handlers.py +249 -0
  14. lifx_emulator/handlers/registry.py +110 -0
  15. lifx_emulator/handlers/tile_handlers.py +309 -0
  16. lifx_emulator/observers.py +139 -0
  17. lifx_emulator/products/__init__.py +28 -0
  18. lifx_emulator/products/generator.py +771 -0
  19. lifx_emulator/products/registry.py +1446 -0
  20. lifx_emulator/products/specs.py +242 -0
  21. lifx_emulator/products/specs.yml +327 -0
  22. lifx_emulator/protocol/__init__.py +1 -0
  23. lifx_emulator/protocol/base.py +334 -0
  24. lifx_emulator/protocol/const.py +8 -0
  25. lifx_emulator/protocol/generator.py +1371 -0
  26. lifx_emulator/protocol/header.py +159 -0
  27. lifx_emulator/protocol/packets.py +1351 -0
  28. lifx_emulator/protocol/protocol_types.py +844 -0
  29. lifx_emulator/protocol/serializer.py +379 -0
  30. lifx_emulator/scenario_manager.py +402 -0
  31. lifx_emulator/scenario_persistence.py +206 -0
  32. lifx_emulator/server.py +482 -0
  33. lifx_emulator/state_restorer.py +259 -0
  34. lifx_emulator/state_serializer.py +130 -0
  35. lifx_emulator/storage_protocol.py +100 -0
  36. lifx_emulator-1.0.0.dist-info/METADATA +445 -0
  37. lifx_emulator-1.0.0.dist-info/RECORD +40 -0
  38. lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
  39. lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
  40. lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
@@ -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