lifx-emulator 2.3.1__py3-none-any.whl → 3.0.1__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.0.1.dist-info/METADATA +102 -0
- lifx_emulator-3.0.1.dist-info/RECORD +18 -0
- lifx_emulator-3.0.1.dist-info/entry_points.txt +2 -0
- lifx_emulator_app/__init__.py +10 -0
- {lifx_emulator → lifx_emulator_app}/__main__.py +13 -5
- {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/app.py +3 -3
- {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
- lifx_emulator_app/api/routers/__init__.py +11 -0
- {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
- {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
- lifx_emulator_app/api/services/__init__.py +8 -0
- {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
- lifx_emulator/__init__.py +0 -31
- lifx_emulator/api/routers/__init__.py +0 -11
- lifx_emulator/api/services/__init__.py +0 -8
- lifx_emulator/constants.py +0 -33
- lifx_emulator/devices/__init__.py +0 -37
- lifx_emulator/devices/device.py +0 -339
- lifx_emulator/devices/manager.py +0 -256
- lifx_emulator/devices/observers.py +0 -139
- lifx_emulator/devices/persistence.py +0 -308
- lifx_emulator/devices/state_restorer.py +0 -259
- lifx_emulator/devices/state_serializer.py +0 -157
- lifx_emulator/devices/states.py +0 -377
- lifx_emulator/factories/__init__.py +0 -37
- lifx_emulator/factories/builder.py +0 -373
- lifx_emulator/factories/default_config.py +0 -158
- lifx_emulator/factories/factory.py +0 -221
- lifx_emulator/factories/firmware_config.py +0 -77
- lifx_emulator/factories/serial_generator.py +0 -82
- lifx_emulator/handlers/__init__.py +0 -39
- lifx_emulator/handlers/base.py +0 -49
- lifx_emulator/handlers/device_handlers.py +0 -322
- lifx_emulator/handlers/light_handlers.py +0 -503
- lifx_emulator/handlers/multizone_handlers.py +0 -249
- lifx_emulator/handlers/registry.py +0 -110
- lifx_emulator/handlers/tile_handlers.py +0 -488
- lifx_emulator/products/__init__.py +0 -28
- lifx_emulator/products/generator.py +0 -1037
- lifx_emulator/products/registry.py +0 -1496
- lifx_emulator/products/specs.py +0 -284
- lifx_emulator/products/specs.yml +0 -352
- lifx_emulator/protocol/__init__.py +0 -1
- lifx_emulator/protocol/base.py +0 -446
- lifx_emulator/protocol/const.py +0 -8
- lifx_emulator/protocol/generator.py +0 -1384
- lifx_emulator/protocol/header.py +0 -159
- lifx_emulator/protocol/packets.py +0 -1351
- lifx_emulator/protocol/protocol_types.py +0 -817
- lifx_emulator/protocol/serializer.py +0 -379
- lifx_emulator/repositories/__init__.py +0 -22
- lifx_emulator/repositories/device_repository.py +0 -155
- lifx_emulator/repositories/storage_backend.py +0 -107
- lifx_emulator/scenarios/__init__.py +0 -22
- lifx_emulator/scenarios/manager.py +0 -322
- lifx_emulator/scenarios/models.py +0 -112
- lifx_emulator/scenarios/persistence.py +0 -241
- lifx_emulator/server.py +0 -464
- lifx_emulator-2.3.1.dist-info/METADATA +0 -107
- lifx_emulator-2.3.1.dist-info/RECORD +0 -62
- lifx_emulator-2.3.1.dist-info/entry_points.txt +0 -2
- lifx_emulator-2.3.1.dist-info/licenses/LICENSE +0 -35
- {lifx_emulator-2.3.1.dist-info → lifx_emulator-3.0.1.dist-info}/WHEEL +0 -0
- {lifx_emulator → lifx_emulator_app}/api/templates/dashboard.html +0 -0
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
"""Binary serialization for LIFX protocol packets.
|
|
2
|
-
|
|
3
|
-
Handles packing and unpacking of protocol structures using struct module.
|
|
4
|
-
All multi-byte values use little-endian byte order per LIFX specification.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import struct
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
# Type format mapping for struct module (little-endian)
|
|
13
|
-
TYPE_FORMATS: dict[str, str] = {
|
|
14
|
-
"uint8": "B",
|
|
15
|
-
"uint16": "H",
|
|
16
|
-
"uint32": "I",
|
|
17
|
-
"uint64": "Q",
|
|
18
|
-
"int8": "b",
|
|
19
|
-
"int16": "h",
|
|
20
|
-
"int32": "i",
|
|
21
|
-
"int64": "q",
|
|
22
|
-
"float32": "f",
|
|
23
|
-
"bool": "?",
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
# Type sizes in bytes
|
|
27
|
-
TYPE_SIZES: dict[str, int] = {
|
|
28
|
-
"uint8": 1,
|
|
29
|
-
"uint16": 2,
|
|
30
|
-
"uint32": 4,
|
|
31
|
-
"uint64": 8,
|
|
32
|
-
"int8": 1,
|
|
33
|
-
"int16": 2,
|
|
34
|
-
"int32": 4,
|
|
35
|
-
"int64": 8,
|
|
36
|
-
"float32": 4,
|
|
37
|
-
"bool": 1,
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
# Pre-compiled struct.Struct objects for faster pack/unpack (optimization)
|
|
41
|
-
_STRUCT_CACHE: dict[str, struct.Struct] = {
|
|
42
|
-
"uint8": struct.Struct("<B"),
|
|
43
|
-
"uint16": struct.Struct("<H"),
|
|
44
|
-
"uint32": struct.Struct("<I"),
|
|
45
|
-
"uint64": struct.Struct("<Q"),
|
|
46
|
-
"int8": struct.Struct("<b"),
|
|
47
|
-
"int16": struct.Struct("<h"),
|
|
48
|
-
"int32": struct.Struct("<i"),
|
|
49
|
-
"int64": struct.Struct("<q"),
|
|
50
|
-
"float32": struct.Struct("<f"),
|
|
51
|
-
"bool": struct.Struct("<?"),
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def get_type_size(type_name: str) -> int:
|
|
56
|
-
"""Get the size in bytes of a type.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
type_name: Type name (e.g., 'uint16', 'float32')
|
|
60
|
-
|
|
61
|
-
Returns:
|
|
62
|
-
Size in bytes
|
|
63
|
-
|
|
64
|
-
Raises:
|
|
65
|
-
ValueError: If type is unknown
|
|
66
|
-
"""
|
|
67
|
-
if type_name not in TYPE_SIZES:
|
|
68
|
-
raise ValueError(f"Unknown type: {type_name}")
|
|
69
|
-
return TYPE_SIZES[type_name]
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def pack_value(value: Any, type_name: str) -> bytes:
|
|
73
|
-
"""Pack a single value into bytes.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
value: Value to pack
|
|
77
|
-
type_name: Type name (e.g., 'uint16', 'float32')
|
|
78
|
-
|
|
79
|
-
Returns:
|
|
80
|
-
Packed bytes
|
|
81
|
-
|
|
82
|
-
Raises:
|
|
83
|
-
ValueError: If type is unknown
|
|
84
|
-
struct.error: If value doesn't match type
|
|
85
|
-
"""
|
|
86
|
-
if type_name not in _STRUCT_CACHE:
|
|
87
|
-
raise ValueError(f"Unknown type: {type_name}")
|
|
88
|
-
|
|
89
|
-
return _STRUCT_CACHE[type_name].pack(value)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def unpack_value(data: bytes, type_name: str, offset: int = 0) -> tuple[Any, int]:
|
|
93
|
-
"""Unpack a single value from bytes.
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
data: Bytes to unpack from
|
|
97
|
-
type_name: Type name (e.g., 'uint16', 'float32')
|
|
98
|
-
offset: Offset in bytes to start unpacking
|
|
99
|
-
|
|
100
|
-
Returns:
|
|
101
|
-
Tuple of (unpacked_value, new_offset)
|
|
102
|
-
|
|
103
|
-
Raises:
|
|
104
|
-
ValueError: If type is unknown or data is too short
|
|
105
|
-
struct.error: If data format is invalid
|
|
106
|
-
"""
|
|
107
|
-
if type_name not in _STRUCT_CACHE:
|
|
108
|
-
raise ValueError(f"Unknown type: {type_name}")
|
|
109
|
-
|
|
110
|
-
size = TYPE_SIZES[type_name]
|
|
111
|
-
|
|
112
|
-
if len(data) < offset + size:
|
|
113
|
-
raise ValueError(
|
|
114
|
-
f"Not enough data to unpack {type_name}: "
|
|
115
|
-
f"need {offset + size} bytes, got {len(data)}"
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
value = _STRUCT_CACHE[type_name].unpack_from(data, offset)[0]
|
|
119
|
-
return value, offset + size
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def pack_array(values: list[Any], element_type: str, count: int) -> bytes:
|
|
123
|
-
"""Pack an array of values into bytes.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
values: List of values to pack
|
|
127
|
-
element_type: Type of each element (e.g., 'uint8', 'uint16')
|
|
128
|
-
count: Expected number of elements
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
Packed bytes
|
|
132
|
-
|
|
133
|
-
Raises:
|
|
134
|
-
ValueError: If values length doesn't match count or type is unknown
|
|
135
|
-
"""
|
|
136
|
-
if len(values) != count:
|
|
137
|
-
raise ValueError(f"Expected {count} values, got {len(values)}")
|
|
138
|
-
|
|
139
|
-
# Optimization: Pack entire primitive array at once with single struct call
|
|
140
|
-
if element_type in TYPE_FORMATS:
|
|
141
|
-
format_str = f"<{count}{TYPE_FORMATS[element_type]}"
|
|
142
|
-
return struct.pack(format_str, *values)
|
|
143
|
-
|
|
144
|
-
# Fall back to element-by-element for complex types
|
|
145
|
-
result = b""
|
|
146
|
-
for value in values:
|
|
147
|
-
result += pack_value(value, element_type)
|
|
148
|
-
return result
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def unpack_array(
|
|
152
|
-
data: bytes, element_type: str, count: int, offset: int = 0
|
|
153
|
-
) -> tuple[list[Any], int]:
|
|
154
|
-
"""Unpack an array of values from bytes.
|
|
155
|
-
|
|
156
|
-
Args:
|
|
157
|
-
data: Bytes to unpack from
|
|
158
|
-
element_type: Type of each element
|
|
159
|
-
count: Number of elements to unpack
|
|
160
|
-
offset: Offset in bytes to start unpacking
|
|
161
|
-
|
|
162
|
-
Returns:
|
|
163
|
-
Tuple of (list_of_values, new_offset)
|
|
164
|
-
"""
|
|
165
|
-
# Optimization: Unpack entire primitive array at once with single struct call
|
|
166
|
-
if element_type in TYPE_FORMATS:
|
|
167
|
-
format_str = f"<{count}{TYPE_FORMATS[element_type]}"
|
|
168
|
-
size = TYPE_SIZES[element_type] * count
|
|
169
|
-
|
|
170
|
-
if len(data) < offset + size:
|
|
171
|
-
raise ValueError(
|
|
172
|
-
f"Not enough data to unpack array: "
|
|
173
|
-
f"need {offset + size} bytes, got {len(data)}"
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
values = list(struct.unpack_from(format_str, data, offset))
|
|
177
|
-
return values, offset + size
|
|
178
|
-
|
|
179
|
-
# Fall back to element-by-element for complex types
|
|
180
|
-
values = []
|
|
181
|
-
current_offset = offset
|
|
182
|
-
|
|
183
|
-
for _ in range(count):
|
|
184
|
-
value, current_offset = unpack_value(data, element_type, current_offset)
|
|
185
|
-
values.append(value)
|
|
186
|
-
|
|
187
|
-
return values, current_offset
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def pack_string(value: str, length: int) -> bytes:
|
|
191
|
-
"""Pack a string into fixed-length byte array.
|
|
192
|
-
|
|
193
|
-
Safely truncates at UTF-8 character boundaries to avoid creating
|
|
194
|
-
invalid UTF-8 sequences that could crash device firmware (VUL-002 mitigation).
|
|
195
|
-
|
|
196
|
-
Args:
|
|
197
|
-
value: String to pack
|
|
198
|
-
length: Fixed length in bytes
|
|
199
|
-
|
|
200
|
-
Returns:
|
|
201
|
-
Packed bytes (null-padded if necessary)
|
|
202
|
-
"""
|
|
203
|
-
encoded = value.encode("utf-8")
|
|
204
|
-
|
|
205
|
-
# Safe truncation at character boundary
|
|
206
|
-
if len(encoded) > length:
|
|
207
|
-
# Decode and re-encode to find safe truncation point
|
|
208
|
-
truncated = encoded[:length]
|
|
209
|
-
# Find valid UTF-8 boundary by trying to decode
|
|
210
|
-
while truncated:
|
|
211
|
-
try:
|
|
212
|
-
truncated.decode("utf-8")
|
|
213
|
-
break
|
|
214
|
-
except UnicodeDecodeError:
|
|
215
|
-
# Remove last byte and try again
|
|
216
|
-
truncated = truncated[:-1]
|
|
217
|
-
encoded = truncated
|
|
218
|
-
|
|
219
|
-
return encoded.ljust(length, b"\x00")
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def unpack_string(data: bytes, length: int, offset: int = 0) -> tuple[str, int]:
|
|
223
|
-
"""Unpack a fixed-length string from bytes.
|
|
224
|
-
|
|
225
|
-
Args:
|
|
226
|
-
data: Bytes to unpack from
|
|
227
|
-
length: Length in bytes to read
|
|
228
|
-
offset: Offset in bytes to start unpacking
|
|
229
|
-
|
|
230
|
-
Returns:
|
|
231
|
-
Tuple of (string, new_offset)
|
|
232
|
-
"""
|
|
233
|
-
if len(data) < offset + length:
|
|
234
|
-
raise ValueError(
|
|
235
|
-
f"Not enough data to unpack string: "
|
|
236
|
-
f"need {offset + length} bytes, got {len(data)}"
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
raw_bytes = data[offset : offset + length]
|
|
240
|
-
# Strip null bytes and decode
|
|
241
|
-
string = raw_bytes.rstrip(b"\x00").decode("utf-8", errors="replace")
|
|
242
|
-
return string, offset + length
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def pack_reserved(size: int) -> bytes:
|
|
246
|
-
"""Pack reserved (zero) bytes.
|
|
247
|
-
|
|
248
|
-
Args:
|
|
249
|
-
size: Number of bytes
|
|
250
|
-
|
|
251
|
-
Returns:
|
|
252
|
-
Zero bytes
|
|
253
|
-
"""
|
|
254
|
-
return b"\x00" * size
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def pack_bytes(data: bytes, length: int) -> bytes:
|
|
258
|
-
"""Pack bytes into fixed-length byte array.
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
data: Bytes to pack
|
|
262
|
-
length: Fixed length in bytes
|
|
263
|
-
|
|
264
|
-
Returns:
|
|
265
|
-
Packed bytes (null-padded or truncated if necessary)
|
|
266
|
-
"""
|
|
267
|
-
if len(data) >= length:
|
|
268
|
-
return data[:length]
|
|
269
|
-
return data + b"\x00" * (length - len(data))
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def unpack_bytes(data: bytes, length: int, offset: int = 0) -> tuple[bytes, int]:
|
|
273
|
-
"""Unpack fixed-length byte array from bytes.
|
|
274
|
-
|
|
275
|
-
Args:
|
|
276
|
-
data: Bytes to unpack from
|
|
277
|
-
length: Length in bytes to read
|
|
278
|
-
offset: Offset in bytes to start unpacking
|
|
279
|
-
|
|
280
|
-
Returns:
|
|
281
|
-
Tuple of (bytes, new_offset)
|
|
282
|
-
|
|
283
|
-
Raises:
|
|
284
|
-
ValueError: If data is too short
|
|
285
|
-
"""
|
|
286
|
-
if len(data) < offset + length:
|
|
287
|
-
raise ValueError(
|
|
288
|
-
f"Not enough data to unpack bytes: "
|
|
289
|
-
f"need {offset + length} bytes, got {len(data)}"
|
|
290
|
-
)
|
|
291
|
-
|
|
292
|
-
raw_bytes = data[offset : offset + length]
|
|
293
|
-
return raw_bytes, offset + length
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
class FieldSerializer:
|
|
297
|
-
"""Serializer for structured fields with nested types."""
|
|
298
|
-
|
|
299
|
-
def __init__(self, field_definitions: dict[str, dict[str, str]]):
|
|
300
|
-
"""Initialize serializer with field definitions.
|
|
301
|
-
|
|
302
|
-
Args:
|
|
303
|
-
field_definitions: Dict mapping field names to their structure definitions
|
|
304
|
-
(e.g., {"HSBK": {"hue": "uint16", "saturation": "uint16", ...}})
|
|
305
|
-
"""
|
|
306
|
-
self.field_definitions = field_definitions
|
|
307
|
-
|
|
308
|
-
def pack_field(self, field_data: dict[str, Any], field_name: str) -> bytes:
|
|
309
|
-
"""Pack a structured field.
|
|
310
|
-
|
|
311
|
-
Args:
|
|
312
|
-
field_data: Dictionary of field values
|
|
313
|
-
field_name: Name of the field structure (e.g., "HSBK")
|
|
314
|
-
|
|
315
|
-
Returns:
|
|
316
|
-
Packed bytes
|
|
317
|
-
|
|
318
|
-
Raises:
|
|
319
|
-
ValueError: If field_name is unknown
|
|
320
|
-
"""
|
|
321
|
-
if field_name not in self.field_definitions:
|
|
322
|
-
raise ValueError(f"Unknown field: {field_name}")
|
|
323
|
-
|
|
324
|
-
field_def = self.field_definitions[field_name]
|
|
325
|
-
result = b""
|
|
326
|
-
|
|
327
|
-
for attr_name, attr_type in field_def.items():
|
|
328
|
-
if attr_name not in field_data:
|
|
329
|
-
raise ValueError(f"Missing attribute {attr_name} in {field_name}")
|
|
330
|
-
result += pack_value(field_data[attr_name], attr_type)
|
|
331
|
-
|
|
332
|
-
return result
|
|
333
|
-
|
|
334
|
-
def unpack_field(
|
|
335
|
-
self, data: bytes, field_name: str, offset: int = 0
|
|
336
|
-
) -> tuple[dict[str, Any], int]:
|
|
337
|
-
"""Unpack a structured field.
|
|
338
|
-
|
|
339
|
-
Args:
|
|
340
|
-
data: Bytes to unpack from
|
|
341
|
-
field_name: Name of the field structure
|
|
342
|
-
offset: Offset to start unpacking
|
|
343
|
-
|
|
344
|
-
Returns:
|
|
345
|
-
Tuple of (field_dict, new_offset)
|
|
346
|
-
|
|
347
|
-
Raises:
|
|
348
|
-
ValueError: If field_name is unknown
|
|
349
|
-
"""
|
|
350
|
-
if field_name not in self.field_definitions:
|
|
351
|
-
raise ValueError(f"Unknown field: {field_name}")
|
|
352
|
-
|
|
353
|
-
field_def = self.field_definitions[field_name]
|
|
354
|
-
field_data: dict[str, Any] = {}
|
|
355
|
-
current_offset = offset
|
|
356
|
-
|
|
357
|
-
for attr_name, attr_type in field_def.items():
|
|
358
|
-
value, current_offset = unpack_value(data, attr_type, current_offset)
|
|
359
|
-
field_data[attr_name] = value
|
|
360
|
-
|
|
361
|
-
return field_data, current_offset
|
|
362
|
-
|
|
363
|
-
def get_field_size(self, field_name: str) -> int:
|
|
364
|
-
"""Get the size in bytes of a field structure.
|
|
365
|
-
|
|
366
|
-
Args:
|
|
367
|
-
field_name: Name of the field structure
|
|
368
|
-
|
|
369
|
-
Returns:
|
|
370
|
-
Size in bytes
|
|
371
|
-
|
|
372
|
-
Raises:
|
|
373
|
-
ValueError: If field_name is unknown
|
|
374
|
-
"""
|
|
375
|
-
if field_name not in self.field_definitions:
|
|
376
|
-
raise ValueError(f"Unknown field: {field_name}")
|
|
377
|
-
|
|
378
|
-
field_def = self.field_definitions[field_name]
|
|
379
|
-
return sum(TYPE_SIZES[attr_type] for attr_type in field_def.values())
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Repository interfaces and implementations for LIFX emulator.
|
|
2
|
-
|
|
3
|
-
This module defines repository abstractions following the Repository Pattern
|
|
4
|
-
and Dependency Inversion Principle. Repositories encapsulate data access logic
|
|
5
|
-
and provide a clean separation between domain logic and data persistence.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from lifx_emulator.repositories.device_repository import (
|
|
9
|
-
DeviceRepository,
|
|
10
|
-
IDeviceRepository,
|
|
11
|
-
)
|
|
12
|
-
from lifx_emulator.repositories.storage_backend import (
|
|
13
|
-
IDeviceStorageBackend,
|
|
14
|
-
IScenarioStorageBackend,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
__all__ = [
|
|
18
|
-
"IDeviceRepository",
|
|
19
|
-
"DeviceRepository",
|
|
20
|
-
"IDeviceStorageBackend",
|
|
21
|
-
"IScenarioStorageBackend",
|
|
22
|
-
]
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
"""Device repository interface and implementation.
|
|
2
|
-
|
|
3
|
-
Provides abstraction for device storage and retrieval operations,
|
|
4
|
-
following the Repository Pattern and Dependency Inversion Principle.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from typing import Protocol, runtime_checkable
|
|
10
|
-
|
|
11
|
-
from lifx_emulator.devices import EmulatedLifxDevice
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@runtime_checkable
|
|
15
|
-
class IDeviceRepository(Protocol):
|
|
16
|
-
"""Interface for device repository operations.
|
|
17
|
-
|
|
18
|
-
This protocol defines the contract for managing device storage and retrieval.
|
|
19
|
-
Concrete implementations can use in-memory storage, databases, or other backends.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
def add(self, device: EmulatedLifxDevice) -> bool:
|
|
23
|
-
"""Add a device to the repository.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
device: Device to add
|
|
27
|
-
|
|
28
|
-
Returns:
|
|
29
|
-
True if device was added, False if device with same serial already exists
|
|
30
|
-
"""
|
|
31
|
-
...
|
|
32
|
-
|
|
33
|
-
def remove(self, serial: str) -> bool:
|
|
34
|
-
"""Remove a device from the repository.
|
|
35
|
-
|
|
36
|
-
Args:
|
|
37
|
-
serial: Serial number of device to remove
|
|
38
|
-
|
|
39
|
-
Returns:
|
|
40
|
-
True if device was removed, False if not found
|
|
41
|
-
"""
|
|
42
|
-
...
|
|
43
|
-
|
|
44
|
-
def get(self, serial: str) -> EmulatedLifxDevice | None:
|
|
45
|
-
"""Get a device by serial number.
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
serial: Serial number to look up
|
|
49
|
-
|
|
50
|
-
Returns:
|
|
51
|
-
Device if found, None otherwise
|
|
52
|
-
"""
|
|
53
|
-
...
|
|
54
|
-
|
|
55
|
-
def get_all(self) -> list[EmulatedLifxDevice]:
|
|
56
|
-
"""Get all devices.
|
|
57
|
-
|
|
58
|
-
Returns:
|
|
59
|
-
List of all devices in the repository
|
|
60
|
-
"""
|
|
61
|
-
...
|
|
62
|
-
|
|
63
|
-
def clear(self) -> int:
|
|
64
|
-
"""Remove all devices from the repository.
|
|
65
|
-
|
|
66
|
-
Returns:
|
|
67
|
-
Number of devices removed
|
|
68
|
-
"""
|
|
69
|
-
...
|
|
70
|
-
|
|
71
|
-
def count(self) -> int:
|
|
72
|
-
"""Get the number of devices in the repository.
|
|
73
|
-
|
|
74
|
-
Returns:
|
|
75
|
-
Number of devices
|
|
76
|
-
"""
|
|
77
|
-
...
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class DeviceRepository:
|
|
81
|
-
"""In-memory device repository implementation.
|
|
82
|
-
|
|
83
|
-
Stores devices in a dictionary keyed by serial number.
|
|
84
|
-
This is the default implementation used by EmulatedLifxServer.
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
def __init__(self) -> None:
|
|
88
|
-
"""Initialize empty device repository."""
|
|
89
|
-
self._devices: dict[str, EmulatedLifxDevice] = {}
|
|
90
|
-
|
|
91
|
-
def add(self, device: EmulatedLifxDevice) -> bool:
|
|
92
|
-
"""Add a device to the repository.
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
device: Device to add
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
True if device was added, False if device with same serial already exists
|
|
99
|
-
"""
|
|
100
|
-
serial = device.state.serial
|
|
101
|
-
if serial in self._devices:
|
|
102
|
-
return False
|
|
103
|
-
self._devices[serial] = device
|
|
104
|
-
return True
|
|
105
|
-
|
|
106
|
-
def remove(self, serial: str) -> bool:
|
|
107
|
-
"""Remove a device from the repository.
|
|
108
|
-
|
|
109
|
-
Args:
|
|
110
|
-
serial: Serial number of device to remove
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
True if device was removed, False if not found
|
|
114
|
-
"""
|
|
115
|
-
if serial in self._devices:
|
|
116
|
-
del self._devices[serial]
|
|
117
|
-
return True
|
|
118
|
-
return False
|
|
119
|
-
|
|
120
|
-
def get(self, serial: str) -> EmulatedLifxDevice | None:
|
|
121
|
-
"""Get a device by serial number.
|
|
122
|
-
|
|
123
|
-
Args:
|
|
124
|
-
serial: Serial number to look up
|
|
125
|
-
|
|
126
|
-
Returns:
|
|
127
|
-
Device if found, None otherwise
|
|
128
|
-
"""
|
|
129
|
-
return self._devices.get(serial)
|
|
130
|
-
|
|
131
|
-
def get_all(self) -> list[EmulatedLifxDevice]:
|
|
132
|
-
"""Get all devices.
|
|
133
|
-
|
|
134
|
-
Returns:
|
|
135
|
-
List of all devices in the repository
|
|
136
|
-
"""
|
|
137
|
-
return list(self._devices.values())
|
|
138
|
-
|
|
139
|
-
def clear(self) -> int:
|
|
140
|
-
"""Remove all devices from the repository.
|
|
141
|
-
|
|
142
|
-
Returns:
|
|
143
|
-
Number of devices removed
|
|
144
|
-
"""
|
|
145
|
-
count = len(self._devices)
|
|
146
|
-
self._devices.clear()
|
|
147
|
-
return count
|
|
148
|
-
|
|
149
|
-
def count(self) -> int:
|
|
150
|
-
"""Get the number of devices in the repository.
|
|
151
|
-
|
|
152
|
-
Returns:
|
|
153
|
-
Number of devices
|
|
154
|
-
"""
|
|
155
|
-
return len(self._devices)
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
"""Storage backend interfaces for device and scenario persistence.
|
|
2
|
-
|
|
3
|
-
Provides abstraction for persistent storage operations,
|
|
4
|
-
following the Repository Pattern and Dependency Inversion Principle.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
10
|
-
|
|
11
|
-
if TYPE_CHECKING:
|
|
12
|
-
from lifx_emulator.scenarios import HierarchicalScenarioManager
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@runtime_checkable
|
|
16
|
-
class IDeviceStorageBackend(Protocol):
|
|
17
|
-
"""Interface for device state persistence operations.
|
|
18
|
-
|
|
19
|
-
This protocol defines the contract for loading and saving device state.
|
|
20
|
-
Concrete implementations can use async file I/O, databases,
|
|
21
|
-
or other storage backends.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
async def save_device_state(self, device_state: Any) -> None:
|
|
25
|
-
"""Save device state to persistent storage (async).
|
|
26
|
-
|
|
27
|
-
Args:
|
|
28
|
-
device_state: DeviceState instance to persist
|
|
29
|
-
"""
|
|
30
|
-
raise NotImplementedError
|
|
31
|
-
|
|
32
|
-
def load_device_state(self, serial: str) -> dict | None:
|
|
33
|
-
"""Load device state from persistent storage (sync).
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
serial: Device serial number (12-character hex string)
|
|
37
|
-
|
|
38
|
-
Returns:
|
|
39
|
-
Dictionary with device state data, or None if not found
|
|
40
|
-
"""
|
|
41
|
-
raise NotImplementedError
|
|
42
|
-
|
|
43
|
-
def delete_device_state(self, serial: str) -> bool:
|
|
44
|
-
"""Delete device state from persistent storage.
|
|
45
|
-
|
|
46
|
-
Args:
|
|
47
|
-
serial: Device serial number
|
|
48
|
-
|
|
49
|
-
Returns:
|
|
50
|
-
True if state was deleted, False if not found
|
|
51
|
-
"""
|
|
52
|
-
raise NotImplementedError
|
|
53
|
-
|
|
54
|
-
def list_devices(self) -> list[str]:
|
|
55
|
-
"""List all device serials with saved state.
|
|
56
|
-
|
|
57
|
-
Returns:
|
|
58
|
-
List of serial numbers
|
|
59
|
-
"""
|
|
60
|
-
raise NotImplementedError
|
|
61
|
-
|
|
62
|
-
def delete_all_device_states(self) -> int:
|
|
63
|
-
"""Delete all device states from persistent storage.
|
|
64
|
-
|
|
65
|
-
Returns:
|
|
66
|
-
Number of device states deleted
|
|
67
|
-
"""
|
|
68
|
-
raise NotImplementedError
|
|
69
|
-
|
|
70
|
-
async def shutdown(self) -> None:
|
|
71
|
-
"""Gracefully shutdown storage backend, flushing pending writes."""
|
|
72
|
-
raise NotImplementedError
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@runtime_checkable
|
|
76
|
-
class IScenarioStorageBackend(Protocol):
|
|
77
|
-
"""Interface for scenario configuration persistence operations.
|
|
78
|
-
|
|
79
|
-
This protocol defines the contract for loading and saving scenario configurations.
|
|
80
|
-
Concrete implementations can use async file I/O, databases,
|
|
81
|
-
or other storage backends.
|
|
82
|
-
"""
|
|
83
|
-
|
|
84
|
-
async def load(self) -> HierarchicalScenarioManager:
|
|
85
|
-
"""Load scenario configuration from persistent storage (async).
|
|
86
|
-
|
|
87
|
-
Returns:
|
|
88
|
-
Scenario manager with loaded configuration, or default manager
|
|
89
|
-
if no saved data
|
|
90
|
-
"""
|
|
91
|
-
raise NotImplementedError
|
|
92
|
-
|
|
93
|
-
async def save(self, manager: HierarchicalScenarioManager) -> None:
|
|
94
|
-
"""Save scenario configuration to persistent storage (async).
|
|
95
|
-
|
|
96
|
-
Args:
|
|
97
|
-
manager: Scenario manager whose configuration should be saved
|
|
98
|
-
"""
|
|
99
|
-
raise NotImplementedError
|
|
100
|
-
|
|
101
|
-
async def delete(self) -> bool:
|
|
102
|
-
"""Delete scenario configuration from persistent storage (async).
|
|
103
|
-
|
|
104
|
-
Returns:
|
|
105
|
-
True if configuration was deleted, False if it didn't exist
|
|
106
|
-
"""
|
|
107
|
-
raise NotImplementedError
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Scenario management module for LIFX emulator.
|
|
2
|
-
|
|
3
|
-
This module contains all scenario-related functionality including:
|
|
4
|
-
- Scenario manager (HierarchicalScenarioManager)
|
|
5
|
-
- Scenario models (ScenarioConfig)
|
|
6
|
-
- Scenario persistence (async file storage)
|
|
7
|
-
- Device type classification (get_device_type)
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from lifx_emulator.scenarios.manager import (
|
|
11
|
-
HierarchicalScenarioManager,
|
|
12
|
-
get_device_type,
|
|
13
|
-
)
|
|
14
|
-
from lifx_emulator.scenarios.models import ScenarioConfig
|
|
15
|
-
from lifx_emulator.scenarios.persistence import ScenarioPersistenceAsyncFile
|
|
16
|
-
|
|
17
|
-
__all__ = [
|
|
18
|
-
"HierarchicalScenarioManager",
|
|
19
|
-
"ScenarioConfig",
|
|
20
|
-
"ScenarioPersistenceAsyncFile",
|
|
21
|
-
"get_device_type",
|
|
22
|
-
]
|