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,259 @@
1
+ """State restoration for devices with persistent storage.
2
+
3
+ This module provides centralized state restoration logic, eliminating
4
+ duplication between factories and device initialization.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from lifx_emulator.device import DeviceState
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class StateRestorer:
18
+ """Handles restoration of device state from persistent storage.
19
+
20
+ Consolidates state restoration logic that was previously duplicated
21
+ between factories and device initialization.
22
+ """
23
+
24
+ def __init__(self, storage: Any):
25
+ """Initialize state restorer.
26
+
27
+ Args:
28
+ storage: Storage instance (DeviceStorage or AsyncDeviceStorage)
29
+ """
30
+ self.storage = storage
31
+
32
+ def restore_if_available(self, state: DeviceState) -> DeviceState:
33
+ """Restore saved state if available and compatible.
34
+
35
+ Args:
36
+ state: DeviceState to restore into
37
+
38
+ Returns:
39
+ The same DeviceState instance with restored values
40
+ """
41
+ if not self.storage:
42
+ return state
43
+
44
+ saved_state = self.storage.load_device_state(state.serial)
45
+ if not saved_state:
46
+ logger.debug("No saved state found for device %s", state.serial)
47
+ return state
48
+
49
+ # Only restore if product matches
50
+ if saved_state.get("product") != state.product:
51
+ logger.warning(
52
+ "Saved state for %s has different product (%s vs %s), skipping restore",
53
+ state.serial,
54
+ saved_state.get("product"),
55
+ state.product,
56
+ )
57
+ return state
58
+
59
+ logger.info("Restoring saved state for device %s", state.serial)
60
+
61
+ # Restore core state
62
+ self._restore_core_state(state, saved_state)
63
+
64
+ # Restore location and group
65
+ self._restore_location_and_group(state, saved_state)
66
+
67
+ # Restore capability-specific state
68
+ self._restore_capability_state(state, saved_state)
69
+
70
+ return state
71
+
72
+ def _restore_core_state(
73
+ self, state: DeviceState, saved_state: dict[str, Any]
74
+ ) -> None:
75
+ """Restore core device state fields.
76
+
77
+ Args:
78
+ state: DeviceState to restore into
79
+ saved_state: Dictionary with saved state values
80
+ """
81
+ if "label" in saved_state:
82
+ state.core.label = saved_state["label"]
83
+ if "power_level" in saved_state:
84
+ state.core.power_level = saved_state["power_level"]
85
+ if "color" in saved_state:
86
+ state.core.color = saved_state["color"]
87
+
88
+ def _restore_location_and_group(
89
+ self, state: DeviceState, saved_state: dict[str, Any]
90
+ ) -> None:
91
+ """Restore location and group metadata.
92
+
93
+ Args:
94
+ state: DeviceState to restore into
95
+ saved_state: Dictionary with saved state values
96
+ """
97
+ # Location
98
+ if "location_id" in saved_state:
99
+ state.location.location_id = saved_state["location_id"]
100
+ if "location_label" in saved_state:
101
+ state.location.location_label = saved_state["location_label"]
102
+ if "location_updated_at" in saved_state:
103
+ state.location.location_updated_at = saved_state["location_updated_at"]
104
+
105
+ # Group
106
+ if "group_id" in saved_state:
107
+ state.group.group_id = saved_state["group_id"]
108
+ if "group_label" in saved_state:
109
+ state.group.group_label = saved_state["group_label"]
110
+ if "group_updated_at" in saved_state:
111
+ state.group.group_updated_at = saved_state["group_updated_at"]
112
+
113
+ def _restore_capability_state(
114
+ self, state: DeviceState, saved_state: dict[str, Any]
115
+ ) -> None:
116
+ """Restore capability-specific state.
117
+
118
+ Args:
119
+ state: DeviceState to restore into
120
+ saved_state: Dictionary with saved state values
121
+ """
122
+ # Infrared
123
+ if (
124
+ state.has_infrared
125
+ and state.infrared
126
+ and "infrared_brightness" in saved_state
127
+ ):
128
+ state.infrared.infrared_brightness = saved_state["infrared_brightness"]
129
+
130
+ # HEV
131
+ if state.has_hev and state.hev:
132
+ if "hev_cycle_duration_s" in saved_state:
133
+ state.hev.hev_cycle_duration_s = saved_state["hev_cycle_duration_s"]
134
+ if "hev_cycle_remaining_s" in saved_state:
135
+ state.hev.hev_cycle_remaining_s = saved_state["hev_cycle_remaining_s"]
136
+ if "hev_cycle_last_power" in saved_state:
137
+ state.hev.hev_cycle_last_power = saved_state["hev_cycle_last_power"]
138
+ if "hev_indication" in saved_state:
139
+ state.hev.hev_indication = saved_state["hev_indication"]
140
+ if "hev_last_result" in saved_state:
141
+ state.hev.hev_last_result = saved_state["hev_last_result"]
142
+
143
+ # Multizone
144
+ if state.has_multizone and state.multizone:
145
+ self._restore_multizone_state(state, saved_state)
146
+
147
+ # Matrix (Tile)
148
+ if state.has_matrix and state.matrix:
149
+ self._restore_matrix_state(state, saved_state)
150
+
151
+ def _restore_multizone_state(
152
+ self, state: DeviceState, saved_state: dict[str, Any]
153
+ ) -> None:
154
+ """Restore multizone-specific state.
155
+
156
+ Args:
157
+ state: DeviceState to restore into
158
+ saved_state: Dictionary with saved state values
159
+ """
160
+ if state.multizone is None:
161
+ return
162
+
163
+ # First restore zone_count from saved state
164
+ # This ensures the device matches what was previously saved
165
+ if "zone_count" in saved_state:
166
+ state.multizone.zone_count = saved_state["zone_count"]
167
+ logger.debug("Restored zone_count: %s", state.multizone.zone_count)
168
+
169
+ # Now restore zone colors if available
170
+ if "zone_colors" in saved_state:
171
+ # Verify zone count matches (should match now that we restored it)
172
+ if len(saved_state["zone_colors"]) == state.multizone.zone_count:
173
+ state.multizone.zone_colors = saved_state["zone_colors"]
174
+ logger.debug("Restored %s zone colors", len(saved_state["zone_colors"]))
175
+ else:
176
+ logger.warning(
177
+ "Zone count mismatch: saved has %s zones, current has %s zones",
178
+ len(saved_state["zone_colors"]),
179
+ state.multizone.zone_count,
180
+ )
181
+
182
+ if "multizone_effect_type" in saved_state:
183
+ state.multizone.effect_type = saved_state["multizone_effect_type"]
184
+ if "multizone_effect_speed" in saved_state:
185
+ state.multizone.effect_speed = saved_state["multizone_effect_speed"]
186
+
187
+ def _restore_matrix_state(
188
+ self, state: DeviceState, saved_state: dict[str, Any]
189
+ ) -> None:
190
+ """Restore matrix (tile) specific state.
191
+
192
+ Args:
193
+ state: DeviceState to restore into
194
+ saved_state: Dictionary with saved state values
195
+ """
196
+ if state.matrix is None:
197
+ return
198
+
199
+ # First restore tile configuration (count, width, height) from saved state
200
+ # This ensures the device matches what was previously saved
201
+ if "tile_count" in saved_state:
202
+ state.matrix.tile_count = saved_state["tile_count"]
203
+ logger.debug("Restored tile_count: %s", state.matrix.tile_count)
204
+ if "tile_width" in saved_state:
205
+ state.matrix.tile_width = saved_state["tile_width"]
206
+ logger.debug("Restored tile_width: %s", state.matrix.tile_width)
207
+ if "tile_height" in saved_state:
208
+ state.matrix.tile_height = saved_state["tile_height"]
209
+ logger.debug("Restored tile_height: %s", state.matrix.tile_height)
210
+
211
+ # Now restore tile devices if available
212
+ if "tile_devices" in saved_state:
213
+ saved_tiles = saved_state["tile_devices"]
214
+ # Verify tile count matches (should match now that we restored it)
215
+ if len(saved_tiles) == state.matrix.tile_count:
216
+ # Verify all tiles have matching dimensions
217
+ if all(
218
+ t["width"] == state.matrix.tile_width
219
+ and t["height"] == state.matrix.tile_height
220
+ for t in saved_tiles
221
+ ):
222
+ state.matrix.tile_devices = saved_tiles
223
+ logger.debug("Restored %s tile devices", len(saved_tiles))
224
+ else:
225
+ logger.warning(
226
+ "Tile dimensions mismatch, skipping tile restoration"
227
+ )
228
+ else:
229
+ logger.warning(
230
+ f"Tile count mismatch: saved has {len(saved_tiles)} tiles, "
231
+ f"current has {state.matrix.tile_count} tiles"
232
+ )
233
+
234
+ if "tile_effect_type" in saved_state:
235
+ state.matrix.effect_type = saved_state["tile_effect_type"]
236
+ if "tile_effect_speed" in saved_state:
237
+ state.matrix.effect_speed = saved_state["tile_effect_speed"]
238
+ if "tile_effect_palette_count" in saved_state:
239
+ state.matrix.effect_palette_count = saved_state["tile_effect_palette_count"]
240
+ if "tile_effect_palette" in saved_state:
241
+ state.matrix.effect_palette = saved_state["tile_effect_palette"]
242
+
243
+
244
+ class NullStateRestorer:
245
+ """No-op state restorer for devices without persistence.
246
+
247
+ Allows code to unconditionally call restore without checking for None.
248
+ """
249
+
250
+ def restore_if_available(self, state: DeviceState) -> DeviceState:
251
+ """No-op restoration.
252
+
253
+ Args:
254
+ state: DeviceState (returned unchanged)
255
+
256
+ Returns:
257
+ The same DeviceState instance
258
+ """
259
+ return state
@@ -0,0 +1,130 @@
1
+ """Shared serialization logic for device state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from lifx_emulator.protocol.protocol_types import LightHsbk
8
+
9
+
10
+ def serialize_hsbk(hsbk: LightHsbk) -> dict[str, int]:
11
+ """Serialize LightHsbk to dict."""
12
+ return {
13
+ "hue": hsbk.hue,
14
+ "saturation": hsbk.saturation,
15
+ "brightness": hsbk.brightness,
16
+ "kelvin": hsbk.kelvin,
17
+ }
18
+
19
+
20
+ def deserialize_hsbk(data: dict[str, int]) -> LightHsbk:
21
+ """Deserialize dict to LightHsbk."""
22
+ return LightHsbk(
23
+ hue=data["hue"],
24
+ saturation=data["saturation"],
25
+ brightness=data["brightness"],
26
+ kelvin=data["kelvin"],
27
+ )
28
+
29
+
30
+ def serialize_device_state(device_state: Any) -> dict[str, Any]:
31
+ """Serialize DeviceState to dict.
32
+
33
+ Note: Accesses state via properties for backward compatibility with composed state.
34
+ """
35
+ state_dict = {
36
+ "serial": device_state.serial,
37
+ "label": device_state.label,
38
+ "product": device_state.product,
39
+ "power_level": device_state.power_level,
40
+ "color": serialize_hsbk(device_state.color),
41
+ "location_id": device_state.location_id.hex(),
42
+ "location_label": device_state.location_label,
43
+ "location_updated_at": device_state.location_updated_at,
44
+ "group_id": device_state.group_id.hex(),
45
+ "group_label": device_state.group_label,
46
+ "group_updated_at": device_state.group_updated_at,
47
+ "has_color": device_state.has_color,
48
+ "has_infrared": device_state.has_infrared,
49
+ "has_multizone": device_state.has_multizone,
50
+ "has_matrix": device_state.has_matrix,
51
+ "has_hev": device_state.has_hev,
52
+ }
53
+
54
+ if device_state.has_infrared:
55
+ state_dict["infrared_brightness"] = device_state.infrared_brightness
56
+
57
+ if device_state.has_hev:
58
+ state_dict["hev_cycle_duration_s"] = device_state.hev_cycle_duration_s
59
+ state_dict["hev_cycle_remaining_s"] = device_state.hev_cycle_remaining_s
60
+ state_dict["hev_cycle_last_power"] = device_state.hev_cycle_last_power
61
+ state_dict["hev_indication"] = device_state.hev_indication
62
+ state_dict["hev_last_result"] = device_state.hev_last_result
63
+
64
+ if device_state.has_multizone:
65
+ state_dict["zone_count"] = device_state.zone_count
66
+ state_dict["zone_colors"] = [
67
+ serialize_hsbk(c) for c in device_state.zone_colors
68
+ ]
69
+ state_dict["multizone_effect_type"] = device_state.multizone_effect_type
70
+ state_dict["multizone_effect_speed"] = device_state.multizone_effect_speed
71
+
72
+ if device_state.has_matrix:
73
+ state_dict["tile_count"] = device_state.tile_count
74
+ state_dict["tile_width"] = device_state.tile_width
75
+ state_dict["tile_height"] = device_state.tile_height
76
+ state_dict["tile_effect_type"] = device_state.tile_effect_type
77
+ state_dict["tile_effect_speed"] = device_state.tile_effect_speed
78
+ state_dict["tile_effect_palette_count"] = device_state.tile_effect_palette_count
79
+ state_dict["tile_effect_palette"] = [
80
+ serialize_hsbk(c) for c in device_state.tile_effect_palette
81
+ ]
82
+ state_dict["tile_devices"] = [
83
+ {
84
+ "accel_meas_x": t["accel_meas_x"],
85
+ "accel_meas_y": t["accel_meas_y"],
86
+ "accel_meas_z": t["accel_meas_z"],
87
+ "user_x": t["user_x"],
88
+ "user_y": t["user_y"],
89
+ "width": t["width"],
90
+ "height": t["height"],
91
+ "device_version_vendor": t["device_version_vendor"],
92
+ "device_version_product": t["device_version_product"],
93
+ "firmware_build": t["firmware_build"],
94
+ "firmware_version_minor": t["firmware_version_minor"],
95
+ "firmware_version_major": t["firmware_version_major"],
96
+ "colors": [serialize_hsbk(c) for c in t["colors"]],
97
+ }
98
+ for t in device_state.tile_devices
99
+ ]
100
+
101
+ return state_dict
102
+
103
+
104
+ def deserialize_device_state(state_dict: dict[str, Any]) -> dict[str, Any]:
105
+ """Deserialize device state dict (convert hex strings and nested objects)."""
106
+ # Deserialize bytes fields
107
+ state_dict["location_id"] = bytes.fromhex(state_dict["location_id"])
108
+ state_dict["group_id"] = bytes.fromhex(state_dict["group_id"])
109
+
110
+ # Deserialize color
111
+ state_dict["color"] = deserialize_hsbk(state_dict["color"])
112
+
113
+ # Deserialize zone colors if present
114
+ if "zone_colors" in state_dict:
115
+ state_dict["zone_colors"] = [
116
+ deserialize_hsbk(c) for c in state_dict["zone_colors"]
117
+ ]
118
+
119
+ # Deserialize tile effect palette if present
120
+ if "tile_effect_palette" in state_dict:
121
+ state_dict["tile_effect_palette"] = [
122
+ deserialize_hsbk(c) for c in state_dict["tile_effect_palette"]
123
+ ]
124
+
125
+ # Deserialize tile devices if present
126
+ if "tile_devices" in state_dict:
127
+ for tile_dict in state_dict["tile_devices"]:
128
+ tile_dict["colors"] = [deserialize_hsbk(c) for c in tile_dict["colors"]]
129
+
130
+ return state_dict
@@ -0,0 +1,100 @@
1
+ """Storage protocol definition for device state persistence.
2
+
3
+ This module defines the common interface that all storage implementations
4
+ must follow, enabling polymorphic use and easier testing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+
12
+ @runtime_checkable
13
+ class StorageProtocol(Protocol):
14
+ """Protocol defining the interface for device state storage.
15
+
16
+ Both synchronous (DeviceStorage) and asynchronous (AsyncDeviceStorage)
17
+ implementations must provide these methods.
18
+
19
+ The protocol allows for polymorphic usage and dependency injection,
20
+ improving testability and adherence to SOLID principles.
21
+ """
22
+
23
+ def load_device_state(self, serial: str) -> dict[str, Any] | None:
24
+ """Load device state from persistent storage.
25
+
26
+ This method is synchronous in both implementations because loading
27
+ primarily happens at device initialization where blocking is acceptable.
28
+
29
+ Args:
30
+ serial: Device serial number
31
+
32
+ Returns:
33
+ Dictionary with device state, or None if not found
34
+ """
35
+ ...
36
+
37
+ def delete_device_state(self, serial: str) -> None:
38
+ """Delete device state from persistent storage.
39
+
40
+ This method is synchronous because deletion is rare and blocking
41
+ is acceptable for this operation.
42
+
43
+ Args:
44
+ serial: Device serial number
45
+ """
46
+ ...
47
+
48
+ def delete_all_device_states(self) -> int:
49
+ """Delete all device states from persistent storage.
50
+
51
+ This method is synchronous because it's typically used for cleanup
52
+ operations where blocking is acceptable.
53
+
54
+ Returns:
55
+ Number of devices deleted
56
+ """
57
+ ...
58
+
59
+ def list_devices(self) -> list[str]:
60
+ """List all devices with saved state.
61
+
62
+ This method is synchronous because listing is typically used for
63
+ administrative/query operations where blocking is acceptable.
64
+
65
+ Returns:
66
+ List of device serial numbers
67
+ """
68
+ ...
69
+
70
+
71
+ @runtime_checkable
72
+ class AsyncStorageProtocol(StorageProtocol, Protocol):
73
+ """Extended protocol for asynchronous storage implementations.
74
+
75
+ Adds async save method for high-performance non-blocking writes.
76
+ """
77
+
78
+ async def save_device_state(self, device_state: Any) -> None:
79
+ """Queue device state for saving (non-blocking).
80
+
81
+ Args:
82
+ device_state: DeviceState instance to persist
83
+ """
84
+ ...
85
+
86
+
87
+ @runtime_checkable
88
+ class SyncStorageProtocol(StorageProtocol, Protocol):
89
+ """Extended protocol for synchronous storage implementations.
90
+
91
+ Adds synchronous save method for simple blocking writes.
92
+ """
93
+
94
+ def save_device_state(self, device_state: Any) -> None:
95
+ """Save device state to persistent storage (blocking).
96
+
97
+ Args:
98
+ device_state: DeviceState instance to persist
99
+ """
100
+ ...