lifx-emulator 1.0.2__py3-none-any.whl → 2.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 (57) hide show
  1. lifx_emulator/__init__.py +1 -1
  2. lifx_emulator/__main__.py +26 -51
  3. lifx_emulator/api/__init__.py +18 -0
  4. lifx_emulator/api/app.py +154 -0
  5. lifx_emulator/api/mappers/__init__.py +5 -0
  6. lifx_emulator/api/mappers/device_mapper.py +114 -0
  7. lifx_emulator/api/models.py +133 -0
  8. lifx_emulator/api/routers/__init__.py +11 -0
  9. lifx_emulator/api/routers/devices.py +130 -0
  10. lifx_emulator/api/routers/monitoring.py +52 -0
  11. lifx_emulator/api/routers/scenarios.py +247 -0
  12. lifx_emulator/api/services/__init__.py +8 -0
  13. lifx_emulator/api/services/device_service.py +198 -0
  14. lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
  15. lifx_emulator/devices/__init__.py +37 -0
  16. lifx_emulator/devices/device.py +333 -0
  17. lifx_emulator/devices/manager.py +256 -0
  18. lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
  19. lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
  20. lifx_emulator/devices/states.py +333 -0
  21. lifx_emulator/factories/__init__.py +37 -0
  22. lifx_emulator/factories/builder.py +371 -0
  23. lifx_emulator/factories/default_config.py +158 -0
  24. lifx_emulator/factories/factory.py +221 -0
  25. lifx_emulator/factories/firmware_config.py +59 -0
  26. lifx_emulator/factories/serial_generator.py +82 -0
  27. lifx_emulator/handlers/base.py +1 -1
  28. lifx_emulator/handlers/device_handlers.py +10 -28
  29. lifx_emulator/handlers/light_handlers.py +5 -9
  30. lifx_emulator/handlers/multizone_handlers.py +1 -1
  31. lifx_emulator/handlers/tile_handlers.py +1 -1
  32. lifx_emulator/products/generator.py +389 -170
  33. lifx_emulator/products/registry.py +52 -40
  34. lifx_emulator/products/specs.py +12 -13
  35. lifx_emulator/protocol/base.py +115 -61
  36. lifx_emulator/protocol/generator.py +18 -5
  37. lifx_emulator/protocol/packets.py +7 -7
  38. lifx_emulator/repositories/__init__.py +22 -0
  39. lifx_emulator/repositories/device_repository.py +155 -0
  40. lifx_emulator/repositories/storage_backend.py +107 -0
  41. lifx_emulator/scenarios/__init__.py +22 -0
  42. lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
  43. lifx_emulator/scenarios/models.py +112 -0
  44. lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
  45. lifx_emulator/server.py +38 -64
  46. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/METADATA +1 -1
  47. lifx_emulator-2.0.0.dist-info/RECORD +62 -0
  48. lifx_emulator/device.py +0 -750
  49. lifx_emulator/device_states.py +0 -114
  50. lifx_emulator/factories.py +0 -380
  51. lifx_emulator/storage_protocol.py +0 -100
  52. lifx_emulator-1.0.2.dist-info/RECORD +0 -40
  53. /lifx_emulator/{observers.py → devices/observers.py} +0 -0
  54. /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
  55. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/WHEEL +0 -0
  56. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/entry_points.txt +0 -0
  57. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  from dataclasses import dataclass
13
13
  from enum import IntEnum
14
+ from functools import cached_property
14
15
 
15
16
 
16
17
  class ProductCapability(IntEnum):
@@ -128,6 +129,53 @@ class ProductInfo:
128
129
  return True
129
130
  return firmware_version >= self.min_ext_mz_firmware
130
131
 
132
+ @cached_property
133
+ def caps(self) -> str:
134
+ """Format product capabilities as a human-readable string.
135
+
136
+ Returns:
137
+ Comma-separated capability string (e.g., "full color, infrared, multizone")
138
+ """
139
+ caps = []
140
+
141
+ # Determine base light type
142
+ if self.has_relays:
143
+ # Devices with relays are switches, not lights
144
+ caps.append("switch")
145
+ elif self.has_color:
146
+ caps.append("full color")
147
+ else:
148
+ # Check temperature range to determine white light type
149
+ if self.temperature_range:
150
+ if self.temperature_range.min != self.temperature_range.max:
151
+ caps.append("color temperature")
152
+ else:
153
+ caps.append("brightness only")
154
+ else:
155
+ # No temperature range info, assume basic brightness
156
+ caps.append("brightness only")
157
+
158
+ # Add additional capabilities
159
+ if self.has_infrared:
160
+ caps.append("infrared")
161
+ # Extended multizone is backwards compatible with multizone,
162
+ # so only show multizone if extended multizone is not present
163
+ if self.has_extended_multizone:
164
+ caps.append("extended-multizone")
165
+ elif self.has_multizone:
166
+ caps.append("multizone")
167
+ if self.has_matrix:
168
+ caps.append("matrix")
169
+ if self.has_hev:
170
+ caps.append("HEV")
171
+ if self.has_chain:
172
+ caps.append("chain")
173
+ if self.has_buttons and not self.has_relays:
174
+ # Only show buttons if not already identified as switch
175
+ caps.append("buttons")
176
+
177
+ return ", ".join(caps) if caps else "unknown"
178
+
131
179
 
132
180
  # Pre-generated product definitions
133
181
  PRODUCTS: dict[int, ProductInfo] = {
@@ -457,22 +505,6 @@ PRODUCTS: dict[int, ProductInfo] = {
457
505
  temperature_range=TemperatureRange(min=1500, max=9000),
458
506
  min_ext_mz_firmware=None,
459
507
  ),
460
- 70: ProductInfo(
461
- pid=70,
462
- name="LIFX Switch",
463
- vendor=1,
464
- capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
465
- temperature_range=None,
466
- min_ext_mz_firmware=None,
467
- ),
468
- 71: ProductInfo(
469
- pid=71,
470
- name="LIFX Switch",
471
- vendor=1,
472
- capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
473
- temperature_range=None,
474
- min_ext_mz_firmware=None,
475
- ),
476
508
  81: ProductInfo(
477
509
  pid=81,
478
510
  name="LIFX Candle White to Warm",
@@ -513,14 +545,6 @@ PRODUCTS: dict[int, ProductInfo] = {
513
545
  temperature_range=TemperatureRange(min=2700, max=2700),
514
546
  min_ext_mz_firmware=None,
515
547
  ),
516
- 89: ProductInfo(
517
- pid=89,
518
- name="LIFX Switch",
519
- vendor=1,
520
- capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
521
- temperature_range=None,
522
- min_ext_mz_firmware=None,
523
- ),
524
548
  90: ProductInfo(
525
549
  pid=90,
526
550
  name="LIFX Clean",
@@ -657,22 +681,6 @@ PRODUCTS: dict[int, ProductInfo] = {
657
681
  temperature_range=TemperatureRange(min=1500, max=9000),
658
682
  min_ext_mz_firmware=None,
659
683
  ),
660
- 115: ProductInfo(
661
- pid=115,
662
- name="LIFX Switch",
663
- vendor=1,
664
- capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
665
- temperature_range=None,
666
- min_ext_mz_firmware=None,
667
- ),
668
- 116: ProductInfo(
669
- pid=116,
670
- name="LIFX Switch",
671
- vendor=1,
672
- capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
673
- temperature_range=None,
674
- min_ext_mz_firmware=None,
675
- ),
676
684
  117: ProductInfo(
677
685
  pid=117,
678
686
  name="LIFX Z US",
@@ -1313,6 +1321,10 @@ class ProductRegistry:
1313
1321
  prod_features = product.get("features", {})
1314
1322
  features: dict[str, Any] = {**default_features, **prod_features}
1315
1323
 
1324
+ # Skip switch products (devices with relays) - these are not lights
1325
+ if features.get("relays"):
1326
+ continue
1327
+
1316
1328
  # Build capabilities bitfield
1317
1329
  capabilities = 0
1318
1330
  if features.get("color"):
@@ -83,19 +83,18 @@ class SpecsRegistry:
83
83
 
84
84
  # Parse product specs
85
85
  for pid, specs_data in data["products"].items():
86
- if isinstance(specs_data, dict):
87
- self._specs[int(pid)] = ProductSpecs(
88
- product_id=int(pid),
89
- default_zone_count=specs_data.get("default_zone_count"),
90
- min_zone_count=specs_data.get("min_zone_count"),
91
- max_zone_count=specs_data.get("max_zone_count"),
92
- default_tile_count=specs_data.get("default_tile_count"),
93
- min_tile_count=specs_data.get("min_tile_count"),
94
- max_tile_count=specs_data.get("max_tile_count"),
95
- tile_width=specs_data.get("tile_width"),
96
- tile_height=specs_data.get("tile_height"),
97
- notes=specs_data.get("notes"),
98
- )
86
+ self._specs[int(pid)] = ProductSpecs(
87
+ product_id=int(pid),
88
+ default_zone_count=specs_data.get("default_zone_count"),
89
+ min_zone_count=specs_data.get("min_zone_count"),
90
+ max_zone_count=specs_data.get("max_zone_count"),
91
+ default_tile_count=specs_data.get("default_tile_count"),
92
+ min_tile_count=specs_data.get("min_tile_count"),
93
+ max_tile_count=specs_data.get("max_tile_count"),
94
+ tile_width=specs_data.get("tile_width"),
95
+ tile_height=specs_data.get("tile_height"),
96
+ notes=specs_data.get("notes"),
97
+ )
99
98
 
100
99
  self._loaded = True
101
100
 
@@ -137,7 +137,7 @@ class Packet:
137
137
 
138
138
  # Unpack field value
139
139
  value, current_offset = cls._unpack_field_value(
140
- data, field_type, size_bytes, current_offset
140
+ data, field_type, size_bytes, current_offset, field_name
141
141
  )
142
142
  field_values[field_name] = value
143
143
 
@@ -195,7 +195,10 @@ class Packet:
195
195
  result += item.pack()
196
196
  return result
197
197
  elif base_type in ("uint8", "byte"):
198
- # Byte array
198
+ # Check if value is a string (Label fields)
199
+ if isinstance(value, str):
200
+ return serializer.pack_string(value, size_bytes)
201
+ # Regular byte array
199
202
  return serializer.pack_bytes(value, size_bytes)
200
203
  else:
201
204
  # Array of primitives
@@ -211,11 +214,105 @@ class Packet:
211
214
  return serializer.pack_value(value, base_type)
212
215
 
213
216
  @classmethod
214
- def _unpack_field_value(
215
- cls, data: bytes, field_type: str, size_bytes: int, offset: int
217
+ def _unpack_array_field(
218
+ cls,
219
+ data: bytes,
220
+ base_type: str,
221
+ array_count: int,
222
+ size_bytes: int,
223
+ offset: int,
224
+ field_name: str,
225
+ is_nested: bool,
226
+ enum_types: dict,
227
+ ) -> tuple[Any, int]:
228
+ """Unpack an array field value."""
229
+ from lifx_emulator.protocol import serializer
230
+
231
+ is_enum = is_nested and base_type in enum_types
232
+
233
+ if is_enum:
234
+ result = []
235
+ current_offset = offset
236
+ enum_class = enum_types[base_type]
237
+ for _ in range(array_count):
238
+ item_raw, current_offset = serializer.unpack_value(
239
+ data, "uint8", current_offset
240
+ )
241
+ result.append(enum_class(item_raw))
242
+ return result, current_offset
243
+ elif is_nested:
244
+ from lifx_emulator.protocol import protocol_types
245
+
246
+ struct_class = getattr(protocol_types, base_type)
247
+ result = []
248
+ current_offset = offset
249
+ for _ in range(array_count):
250
+ if issubclass(struct_class, cls):
251
+ item, current_offset = struct_class._unpack_internal(
252
+ data, current_offset
253
+ )
254
+ else:
255
+ item_result = struct_class.unpack(data, current_offset)
256
+ item, current_offset = item_result # type: ignore[misc]
257
+ result.append(item)
258
+ return result, current_offset
259
+ elif base_type in ("uint8", "byte"):
260
+ if field_name.lower().endswith("label"):
261
+ return serializer.unpack_string(data, size_bytes, offset)
262
+ return serializer.unpack_bytes(data, size_bytes, offset)
263
+ else:
264
+ return serializer.unpack_array(data, base_type, array_count, offset)
265
+
266
+ @classmethod
267
+ def _unpack_single_field(
268
+ cls,
269
+ data: bytes,
270
+ base_type: str,
271
+ offset: int,
272
+ is_nested: bool,
273
+ enum_types: dict,
216
274
  ) -> tuple[Any, int]:
217
- """Unpack a single field value based on its type."""
275
+ """Unpack a non-array field value."""
218
276
  from lifx_emulator.protocol import serializer
277
+
278
+ is_enum = is_nested and base_type in enum_types
279
+
280
+ if is_enum:
281
+ enum_class = enum_types[base_type]
282
+ value_raw, new_offset = serializer.unpack_value(data, "uint8", offset)
283
+ return enum_class(value_raw), new_offset
284
+ elif is_nested:
285
+ from lifx_emulator.protocol import protocol_types
286
+
287
+ struct_class = getattr(protocol_types, base_type)
288
+ if issubclass(struct_class, cls):
289
+ return struct_class._unpack_internal(data, offset)
290
+ else:
291
+ return struct_class.unpack(data, offset)
292
+ else:
293
+ return serializer.unpack_value(data, base_type, offset)
294
+
295
+ @classmethod
296
+ def _unpack_field_value(
297
+ cls,
298
+ data: bytes,
299
+ field_type: str,
300
+ size_bytes: int,
301
+ offset: int,
302
+ field_name: str = "",
303
+ ) -> tuple[Any, int]:
304
+ """Unpack a single field value based on its type.
305
+
306
+ Args:
307
+ data: Bytes to unpack from
308
+ field_type: Protocol field type string
309
+ size_bytes: Size in bytes
310
+ offset: Offset in bytes
311
+ field_name: Optional field name for semantic type detection
312
+
313
+ Returns:
314
+ Tuple of (value, new_offset)
315
+ """
219
316
  from lifx_emulator.protocol.protocol_types import (
220
317
  DeviceService,
221
318
  LightLastHevCycleResult,
@@ -225,10 +322,8 @@ class Packet:
225
322
  MultiZoneExtendedApplicationRequest,
226
323
  )
227
324
 
228
- # Parse field type
229
325
  base_type, array_count, is_nested = cls._parse_field_type(field_type)
230
326
 
231
- # Check if it's an enum (Button/Relay enums excluded)
232
327
  enum_types = {
233
328
  "DeviceService": DeviceService,
234
329
  "LightLastHevCycleResult": LightLastHevCycleResult,
@@ -237,63 +332,22 @@ class Packet:
237
332
  "MultiZoneEffectType": MultiZoneEffectType,
238
333
  "MultiZoneExtendedApplicationRequest": MultiZoneExtendedApplicationRequest,
239
334
  }
240
- is_enum = is_nested and base_type in enum_types
241
335
 
242
- # Handle different field types
243
336
  if array_count:
244
- if is_enum:
245
- # Array of enums
246
- result = []
247
- current_offset = offset
248
- enum_class = enum_types[base_type]
249
- for _ in range(array_count):
250
- item_raw, current_offset = serializer.unpack_value(
251
- data, "uint8", current_offset
252
- )
253
- result.append(enum_class(item_raw))
254
- return result, current_offset
255
- elif is_nested:
256
- # Array of nested structures - need to import dynamically
257
- from lifx_emulator.protocol import protocol_types
258
-
259
- struct_class = getattr(protocol_types, base_type)
260
- result = []
261
- current_offset = offset
262
- for _ in range(array_count):
263
- # Check if it's a Packet subclass or protocol_types class
264
- if issubclass(struct_class, cls):
265
- item, current_offset = struct_class._unpack_internal(
266
- data, current_offset
267
- )
268
- else:
269
- item_result = struct_class.unpack(data, current_offset)
270
- item, current_offset = item_result # type: ignore[misc]
271
- result.append(item)
272
- return result, current_offset
273
- elif base_type in ("uint8", "byte"):
274
- # Byte array
275
- return serializer.unpack_bytes(data, size_bytes, offset)
276
- else:
277
- # Array of primitives
278
- return serializer.unpack_array(data, base_type, array_count, offset)
279
- elif is_enum:
280
- # Single enum
281
- enum_class = enum_types[base_type]
282
- value_raw, new_offset = serializer.unpack_value(data, "uint8", offset)
283
- return enum_class(value_raw), new_offset
284
- elif is_nested:
285
- # Nested structure - import dynamically
286
- from lifx_emulator.protocol import protocol_types
287
-
288
- struct_class = getattr(protocol_types, base_type)
289
- # Check if it's a Packet subclass or protocol_types class
290
- if issubclass(struct_class, cls):
291
- return struct_class._unpack_internal(data, offset)
292
- else:
293
- return struct_class.unpack(data, offset)
337
+ return cls._unpack_array_field(
338
+ data,
339
+ base_type,
340
+ array_count,
341
+ size_bytes,
342
+ offset,
343
+ field_name,
344
+ is_nested,
345
+ enum_types,
346
+ )
294
347
  else:
295
- # Primitive type
296
- return serializer.unpack_value(data, base_type, offset)
348
+ return cls._unpack_single_field(
349
+ data, base_type, offset, is_nested, enum_types
350
+ )
297
351
 
298
352
  @staticmethod
299
353
  def _parse_field_type(field_type: str) -> tuple[str, int | None, bool]:
@@ -300,7 +300,9 @@ def generate_enum_code(enums: dict[str, Any]) -> str:
300
300
 
301
301
 
302
302
  def convert_type_to_python(
303
- field_type: str, type_aliases: dict[str, str] | None = None
303
+ field_type: str,
304
+ type_aliases: dict[str, str] | None = None,
305
+ field_name: str | None = None,
304
306
  ) -> str:
305
307
  """Convert a protocol field type to Python type annotation.
306
308
 
@@ -308,6 +310,8 @@ def convert_type_to_python(
308
310
  field_type: Protocol field type string
309
311
  type_aliases: Optional dict mapping type names to their aliases
310
312
  (for collision resolution)
313
+ field_name: Optional field name for semantic type detection
314
+ (e.g., "Label" fields are strings, not bytes)
311
315
 
312
316
  Returns:
313
317
  Python type annotation string
@@ -323,7 +327,10 @@ def convert_type_to_python(
323
327
  type_name = type_aliases.get(base_type, base_type)
324
328
  return f"list[{type_name}]"
325
329
  elif base_type in ("uint8", "byte"):
326
- # Special case: byte arrays
330
+ # Check if this is a string field (Label fields are UTF-8 strings)
331
+ if field_name and field_name.lower() == "label":
332
+ return "str"
333
+ # Regular byte arrays
327
334
  return "bytes"
328
335
  else:
329
336
  return "list[int]"
@@ -664,7 +671,9 @@ def generate_field_code(
664
671
  protocol_name = field_item["name"]
665
672
  attr_type = field_item["type"]
666
673
  python_name = to_snake_case(protocol_name)
667
- python_type = convert_type_to_python(attr_type)
674
+ python_type = convert_type_to_python(
675
+ attr_type, field_name=protocol_name
676
+ )
668
677
 
669
678
  code.append(f" {python_name}: {python_type}")
670
679
  field_map[python_name] = protocol_name
@@ -673,7 +682,9 @@ def generate_field_code(
673
682
  # Convert to new format for pack/unpack generation
674
683
  for protocol_name, attr_type in field_def.items():
675
684
  python_name = to_snake_case(protocol_name)
676
- python_type = convert_type_to_python(attr_type)
685
+ python_type = convert_type_to_python(
686
+ attr_type, field_name=protocol_name
687
+ )
677
688
  code.append(f" {python_name}: {python_type}")
678
689
  field_map[python_name] = protocol_name
679
690
  # Build fields_data for old format
@@ -832,7 +843,9 @@ def generate_nested_packet_code(
832
843
  protocol_name = field_item["name"]
833
844
  field_type = field_item["type"]
834
845
  python_name = to_snake_case(protocol_name)
835
- python_type = convert_type_to_python(field_type, type_aliases)
846
+ python_type = convert_type_to_python(
847
+ field_type, type_aliases, field_name=protocol_name
848
+ )
836
849
  code.append(f" {python_name}: {python_type}")
837
850
  has_fields = True
838
851
 
@@ -234,7 +234,7 @@ class Device(Packet):
234
234
  _requires_response: ClassVar[bool] = False
235
235
 
236
236
  group: bytes
237
- label: bytes
237
+ label: str
238
238
  updated_at: int
239
239
 
240
240
  @dataclass
@@ -251,7 +251,7 @@ class Device(Packet):
251
251
  _requires_ack: ClassVar[bool] = True
252
252
  _requires_response: ClassVar[bool] = False
253
253
 
254
- label: bytes
254
+ label: str
255
255
 
256
256
  @dataclass
257
257
  class SetLocation(Packet):
@@ -270,7 +270,7 @@ class Device(Packet):
270
270
  _requires_response: ClassVar[bool] = False
271
271
 
272
272
  location: bytes
273
- label: bytes
273
+ label: str
274
274
  updated_at: int
275
275
 
276
276
  @dataclass
@@ -320,7 +320,7 @@ class Device(Packet):
320
320
  _requires_response: ClassVar[bool] = False
321
321
 
322
322
  group: bytes
323
- label: bytes
323
+ label: str
324
324
  updated_at: int
325
325
 
326
326
  @dataclass
@@ -378,7 +378,7 @@ class Device(Packet):
378
378
  _requires_ack: ClassVar[bool] = False
379
379
  _requires_response: ClassVar[bool] = False
380
380
 
381
- label: bytes
381
+ label: str
382
382
 
383
383
  @dataclass
384
384
  class StateLocation(Packet):
@@ -397,7 +397,7 @@ class Device(Packet):
397
397
  _requires_response: ClassVar[bool] = False
398
398
 
399
399
  location: bytes
400
- label: bytes
400
+ label: str
401
401
  updated_at: int
402
402
 
403
403
  @dataclass
@@ -768,7 +768,7 @@ class Light(Packet):
768
768
 
769
769
  color: LightHsbk
770
770
  power: int
771
- label: bytes
771
+ label: str
772
772
 
773
773
  @dataclass
774
774
  class StateHevCycle(Packet):
@@ -0,0 +1,22 @@
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
+ ]
@@ -0,0 +1,155 @@
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)