Storage API Reference¶
Persistent storage for device state across emulator restarts
The storage module provides asynchronous persistent storage of device state using JSON files on disk. When enabled, device state (color, power, label, zones, tiles, etc.) is automatically saved and restored across emulator sessions with high-performance non-blocking I/O.
CLI users
The CLI flags --persistent and --persistent-scenarios are deprecated. Use config file device definitions and config file scenarios instead. The storage API documented here is unaffected and remains available for library users.
Table of Contents¶
Core Components¶
- DevicePersistenceAsyncFile - Async storage handler class
- File Format - JSON state file specification
- State Serialization - Converting state to/from JSON
Concepts¶
- Storage Location - Where files are stored
- Device Lifecycle - State save/load during device lifecycle
- Backup and Restore - Managing saved states
- CLI Integration - Using persistent storage from CLI
DevicePersistenceAsyncFile¶
Main class for handling asynchronous persistent device state storage with smart debouncing and batch writes.
Constructor¶
DevicePersistenceAsyncFile(storage_dir: Path | str = DEFAULT_STORAGE_DIR, debounce_ms: int = 100, batch_size_threshold: int = 50)¶
Initialize an async storage handler for device state persistence.
Parameters:
- storage_dir (Path | str) - Directory to store state files (default: ~/.lifx-emulator)
- debounce_ms (int) - Milliseconds to wait before flushing pending saves (default: 100ms)
- batch_size_threshold (int) - Flush early if queue exceeds this size (default: 50)
Example:
from lifx_emulator.devices import DevicePersistenceAsyncFile
# Use default location (~/.lifx-emulator)
storage = DevicePersistenceAsyncFile()
# Use custom location with custom debounce settings
storage = DevicePersistenceAsyncFile(
"/var/lib/lifx-emulator",
debounce_ms=200,
batch_size_threshold=100
)
Methods¶
async save_device_state(device_state: DeviceState) -> None¶
Queue device state for saving (non-blocking async).
Queues the device state for saving. The write is performed asynchronously with debouncing to minimize I/O overhead.
Parameters:
- device_state (DeviceState) - Device state to persist
Returns: None (task runs in background)
Example:
import asyncio
from lifx_emulator.devices import DevicePersistenceAsyncFile
from lifx_emulator.factories import create_color_light
async def main():
storage = DevicePersistenceAsyncFile()
device = create_color_light("d073d5000001")
device.state.label = "Living Room"
device.state.power_level = 65535
# Queue state for async save (non-blocking)
await storage.save_device_state(device.state)
# Flush pending writes before the event loop exits
await storage.shutdown()
# File is now at: ~/.lifx-emulator/d073d5000001.json
asyncio.run(main())
async shutdown() -> None¶
Flush all pending writes and shut down the storage executor. Call this before your application exits so queued saves reach disk.
load_device_state(serial: str) -> dict[str, Any] | None¶
Load device state from disk (synchronous).
Reads the JSON file for the specified serial and returns the deserialized state dictionary. Returns None if the file doesn't exist or cannot be loaded.
Parameters:
- serial (str) - Device serial to load
Returns: dict[str, Any] | None - State dictionary or None if not found
Example:
from lifx_emulator.devices import DevicePersistenceAsyncFile
storage = DevicePersistenceAsyncFile()
state_dict = storage.load_device_state("d073d5000001")
if state_dict:
print(f"Label: {state_dict['label']}")
print(f"Power: {state_dict['power_level']}")
else:
print("No saved state found")
async delete_device_state(serial: str) -> bool¶
Delete saved state for a device.
Discards any queued write for the serial and removes its JSON file from disk.
Parameters:
- serial (str) - Device serial to delete
Returns: bool - True if a state file was removed
Example:
import asyncio
from lifx_emulator.devices import DevicePersistenceAsyncFile
storage = DevicePersistenceAsyncFile()
asyncio.run(storage.delete_device_state("d073d5000001"))
# Removes: ~/.lifx-emulator/d073d5000001.json
list_devices() -> list[str]¶
List all devices with saved state (synchronous).
Returns a list of device serials that have saved state files in the storage directory.
Returns: list[str] - List of device serials
Example:
from lifx_emulator.devices import DevicePersistenceAsyncFile
storage = DevicePersistenceAsyncFile()
devices = storage.list_devices()
print(f"Found {len(devices)} saved devices:")
for serial in devices:
print(f" - {serial}")
delete_all_device_states() -> int¶
Delete all saved device states (synchronous).
Removes all .json files from the storage directory.
Returns: int - Number of devices deleted
Example:
from lifx_emulator.devices import DevicePersistenceAsyncFile
storage = DevicePersistenceAsyncFile()
count = storage.delete_all_device_states()
print(f"Deleted {count} device states")
File Format¶
Device state is saved as JSON files with the naming convention {serial}.json.
JSON Structure¶
{
"serial": "d073d5000001",
"label": "Living Room Light",
"product": 91,
"power_level": 65535,
"connectivity": "wifi",
"color": {
"hue": 21845,
"saturation": 65535,
"brightness": 32768,
"kelvin": 3500
},
"location_id": "0123456789abcdef0123456789abcdef",
"location_label": "Home",
"location_updated_at": 1790872052870081024,
"group_id": "fedcba9876543210fedcba9876543210",
"group_label": "Living Room",
"group_updated_at": 1790872052870404096,
"has_color": true,
"has_infrared": false,
"has_multizone": false,
"has_matrix": false,
"has_hev": false
}
Capability-specific fields are only written when the device has that capability: infrared_brightness for infrared devices, hev_* fields for HEV devices, zone_count/zone_colors/multizone_effect_* for multizone devices, and tile_* fields for matrix devices.
Multizone Device Example¶
{
"serial": "d073d5800001",
"product": 38,
"label": "Kitchen Strip",
"power_level": 65535,
"color": {
"hue": 0,
"saturation": 0,
"brightness": 65535,
"kelvin": 3500
},
"zone_count": 16,
"zone_colors": [
{"hue": 0, "saturation": 65535, "brightness": 32768, "kelvin": 3500},
{"hue": 21845, "saturation": 65535, "brightness": 32768, "kelvin": 3500},
...
],
"multizone_effect_type": 1,
"multizone_effect_instanceid": 7,
"multizone_effect_speed_ms": 1250,
"multizone_effect_duration": 0,
"multizone_effect_parameters": [0, 1, 0, 0, 0, 0, 0, 0]
}
Effect speeds are saved in milliseconds and effect durations in nanoseconds. Files written by earlier releases store the speed in whole seconds under multizone_effect_speed or tile_effect_speed; those are converted to milliseconds when the state is restored.
Matrix Device Example¶
{
"serial": "d073d5900001",
"product": 55,
"label": "Wall Art",
"power_level": 65535,
"tile_count": 5,
"tile_width": 8,
"tile_height": 8,
"tile_devices": [
{
"accel_meas_x": 0,
"accel_meas_y": 0,
"accel_meas_z": 0,
"user_x": 0.0,
"user_y": 0.0,
"width": 8,
"height": 8,
"device_version_vendor": 1,
"device_version_product": 55,
"firmware_build": 0,
"firmware_version_major": 3,
"firmware_version_minor": 50,
"colors": [...]
},
...
],
"tile_effect_type": 2,
"tile_effect_instanceid": 42,
"tile_effect_speed_ms": 3000,
"tile_effect_duration": 0,
"tile_effect_sky_type": 0,
"tile_effect_cloud_sat_min": 0,
"tile_effect_cloud_sat_max": 0,
"tile_effect_palette_count": 3,
"tile_effect_palette": [...]
}
State Serialization¶
The lifx_emulator.devices.state_serializer module handles conversion between DeviceState objects and JSON-compatible dictionaries.
serialize_device_state(device_state: DeviceState) -> dict¶
Convert DeviceState to JSON-compatible dictionary.
Parameters:
- device_state (DeviceState) - State to serialize
Returns: dict - JSON-compatible dictionary
Example:
from lifx_emulator.devices.state_serializer import serialize_device_state
from lifx_emulator.factories import create_color_light
device = create_color_light("d073d5000001")
device.state.label = "Test Light"
state_dict = serialize_device_state(device.state)
# state_dict is JSON-compatible dict
deserialize_device_state(state_dict: dict) -> dict¶
Convert a JSON dictionary back to DeviceState-compatible values (bytes IDs, LightHsbk colours). The dictionary is modified in place and returned.
Parameters:
- state_dict (dict) - Serialized state dictionary
Returns: dict - Deserialized state dictionary
Example:
from lifx_emulator.devices import DevicePersistenceAsyncFile
storage = DevicePersistenceAsyncFile()
# load_device_state() calls deserialize_device_state() for you
loaded_dict = storage.load_device_state("d073d5000001")
if loaded_dict:
print(f"Label: {loaded_dict['label']}")
# Deserialize a raw state file yourself
with open(storage.storage_dir / "d073d5000001.json") as f:
state_dict = deserialize_device_state(json.load(f))
Storage Location¶
Default Location¶
By default, device state files are stored in:
- Linux/macOS:
~/.lifx-emulator/ - Windows:
C:\Users\{username}\.lifx-emulator\
Custom Location¶
You can specify a custom storage directory:
from pathlib import Path
from lifx_emulator.devices import DevicePersistenceAsyncFile
# Project-specific storage
storage = DevicePersistenceAsyncFile("./lifx_state")
# System-wide storage (requires permissions)
storage = DevicePersistenceAsyncFile("/var/lib/lifx-emulator")
# Temporary storage (for testing)
import tempfile
storage = DevicePersistenceAsyncFile(tempfile.mkdtemp())
Device Lifecycle¶
State Restoration on Device Creation¶
When creating a device with storage enabled, existing state is automatically restored:
import asyncio
from lifx_emulator.devices import DevicePersistenceAsyncFile
from lifx_emulator.factories import create_color_light
async def main():
storage = DevicePersistenceAsyncFile()
# First run: Create device and save state
device = create_color_light(serial="d073d5000001", storage=storage)
device.state.label = "Living Room"
device.state.power_level = 65535
await storage.save_device_state(device.state) # Queue async save
await storage.shutdown() # Flush pending writes
# Later run: State is automatically restored
storage = DevicePersistenceAsyncFile()
device = create_color_light(serial="d073d5000001", storage=storage)
print(device.state.label) # Living Room
print(device.state.power_level) # 65535
asyncio.run(main())
Automatic State Saving¶
Device state is automatically saved when:
- Device properties are updated via state-changing protocol packets (SetColor, SetPower, SetLabel, etc.)
Direct attribute assignments such as device.state.label = "..." are not saved automatically; call save_device_state() yourself, and shutdown() before exit to flush pending writes.
Example with automatic saving:
import asyncio
from lifx_emulator.devices import DevicePersistenceAsyncFile
from lifx_emulator.factories import create_color_light
from lifx_emulator.protocol.header import LifxHeader
from lifx_emulator.protocol.packets import Device
async def main():
storage = DevicePersistenceAsyncFile()
device = create_color_light("d073d5000001", storage=storage)
# Simulate a SetLabel packet
packet = Device.SetLabel(label="Kitchen Light")
header = LifxHeader(
source=1,
target=device.state.get_target_bytes(),
sequence=1,
pkt_type=Device.SetLabel.PKT_TYPE,
)
device.process_packet(header, packet)
# State is automatically queued for saving after processing.
# Drain the device's pending saves, then flush storage to disk.
await device.close()
await storage.shutdown()
print(storage.load_device_state("d073d5000001")["label"]) # Kitchen Light
asyncio.run(main())
Manual State Management¶
For fine-grained control, use manual save/load:
# Manual async save
await storage.save_device_state(device.state)
# Manual load (during initialization)
state_dict = storage.load_device_state(serial)
if state_dict:
# Apply loaded state to device
device.state.label = state_dict['label']
device.state.power_level = state_dict['power_level']
# ... etc
Backup and Restore¶
Creating Backups¶
import shutil
from lifx_emulator.devices import DevicePersistenceAsyncFile
storage = DevicePersistenceAsyncFile()
# Backup entire storage directory
shutil.copytree(storage.storage_dir, "/backup/lifx-emulator-backup")
# Backup single device
device_path = storage.storage_dir / "d073d5000001.json"
shutil.copy(device_path, "/backup/d073d5000001.json.bak")
Restoring from Backup¶
import shutil
# Restore entire storage directory
shutil.copytree("/backup/lifx-emulator-backup", "~/.lifx-emulator", dirs_exist_ok=True)
# Restore single device
shutil.copy("/backup/d073d5000001.json.bak", "~/.lifx-emulator/d073d5000001.json")
Exporting Device State¶
The storage directory can also hold non-device files, such as the scenarios.json written by scenario persistence, so export only files whose names are valid device serials:
import json
import os
import re
from lifx_emulator.devices import DevicePersistenceAsyncFile
SERIAL_RE = re.compile(r"[0-9a-fA-F]{12}")
storage = DevicePersistenceAsyncFile()
root = os.path.realpath(storage.storage_dir)
# Export all device states to a single file. Read the raw JSON files:
# load_device_state() returns deserialised objects that aren't JSON-serialisable.
all_states = {}
for serial in storage.list_devices():
if not SERIAL_RE.fullmatch(serial):
continue # Not a device state file (e.g. scenarios.json)
path = os.path.realpath(os.path.join(root, f"{serial}.json"))
if not path.startswith(root + os.sep):
continue # Symlink pointing outside the storage directory
with open(path) as f:
all_states[serial] = json.load(f)
with open("lifx-export.json", "w") as f:
json.dump(all_states, f, indent=2)
Importing Device State¶
Saved state files are plain JSON, so importing is a matter of writing each state back to the storage directory. The next time a device with that serial is created with the same storage, its state is restored.
The serials come from an untrusted file, so validate every one and check that each destination stays inside the storage directory (resolving symlinks) before writing anything:
import json
import os
import re
from lifx_emulator.devices import DevicePersistenceAsyncFile
from lifx_emulator.factories import create_device
SERIAL_RE = re.compile(r"[0-9a-fA-F]{12}")
storage = DevicePersistenceAsyncFile()
root = os.path.realpath(storage.storage_dir)
# Import from exported file (stop the emulator first)
with open("lifx-export.json") as f:
all_states = json.load(f)
# Validate every serial and destination before writing any files
destinations = {}
for serial in all_states:
if not SERIAL_RE.fullmatch(serial):
raise ValueError(f"Invalid device serial: {serial!r}")
path = os.path.realpath(os.path.join(root, f"{serial}.json"))
if not path.startswith(root + os.sep):
raise ValueError(f"State path for {serial} escapes the storage directory")
destinations[serial] = path
for serial, state_dict in all_states.items():
with open(destinations[serial], "w") as f:
json.dump(state_dict, f, indent=2)
print(f"Imported {len(all_states)} devices")
# Recreate a device; its saved state is restored automatically
serial, state_dict = next(iter(all_states.items()))
device = create_device(state_dict["product"], serial=serial, storage=storage)
print(device.state.label)
CLI Integration¶
Deprecated
The --persistent CLI flag is deprecated. Use config file device definitions instead. Run lifx-emulator export-config to migrate.
Enabling Persistent Storage from CLI¶
Use the --persistent flag to enable state persistence:
# Enable persistence with default location (~/.lifx-emulator)
lifx-emulator --persistent
# Create devices and modify state
# State changes are automatically saved
# Stop and restart emulator - state is restored
lifx-emulator --persistent
Viewing Saved Devices¶
# List saved devices
ls ~/.lifx-emulator/
# View device state
cat ~/.lifx-emulator/d073d5000001.json
# Pretty print
python -m json.tool ~/.lifx-emulator/d073d5000001.json
Clearing Persistent Storage¶
# Remove all saved states
rm -rf ~/.lifx-emulator/
# Remove specific device
rm ~/.lifx-emulator/d073d5000001.json
Programmatic CLI Access¶
from lifx_emulator.devices import DevicePersistenceAsyncFile
storage = DevicePersistenceAsyncFile()
# List devices
print("Saved devices:")
for serial in storage.list_devices():
state = storage.load_device_state(serial)
if state:
print(f" {serial}: {state.get('label', 'Unnamed')}")
# Clear all
count = storage.delete_all_device_states()
print(f"Cleared {count} device states")
Best Practices¶
1. Always Use Same Serials¶
For state persistence to work, devices must use consistent serials:
from lifx_emulator.factories import create_color_light
# Good: Fixed serial
device = create_color_light(serial="d073d5000001", storage=storage)
# Bad: Random serial (state won't persist)
import uuid
device = create_color_light(serial=uuid.uuid4().hex[:12], storage=storage)
2. Handle Storage Errors Gracefully¶
Storage operations may fail due to permissions, disk space, etc:
from lifx_emulator.devices import DevicePersistenceError
try:
await storage.commit_device_state(device.state) # Save and flush to disk now
except DevicePersistenceError as e:
logger.error("Failed to save state: %s", e)
# Continue without persistence
3. Validate Restored State¶
Always validate restored state before using it:
state_dict = storage.load_device_state(serial)
if state_dict:
# Validate product ID matches
if state_dict.get('product') != expected_product:
logger.warning("Product ID mismatch, ignoring saved state")
state_dict = None
4. Use Context Managers for Cleanup¶
Ensure state is saved on cleanup:
import asyncio
from contextlib import asynccontextmanager
from lifx_emulator.devices import DevicePersistenceAsyncFile
from lifx_emulator.factories import create_color_light
storage = DevicePersistenceAsyncFile()
@asynccontextmanager
async def managed_device(serial, storage):
device = create_color_light(serial=serial, storage=storage)
try:
yield device
finally:
# Ensure state is saved on exit
await storage.save_device_state(device.state)
async def main():
async with managed_device("d073d5000001", storage) as device:
# Use device
device.state.power_level = 65535
# State automatically saved on exit
await storage.shutdown() # Flush pending writes
asyncio.run(main())
5. Regular Backups¶
For production use, create regular backups:
#!/bin/bash
# Backup script
BACKUP_DIR="/backup/lifx-emulator/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"
cp -r ~/.lifx-emulator/* "$BACKUP_DIR/"
echo "Backed up to $BACKUP_DIR"
Troubleshooting¶
State Not Persisting¶
Problem: Changes aren't saved between restarts
Solutions:
1. Verify the devices were created with a storage instance (or, for the deprecated CLI flag, that --persistent is used)
2. Check storage directory exists and is writable
3. Ensure consistent serials
4. Check logs for save errors
import logging
logging.basicConfig(level=logging.DEBUG)
# Will show "Flushed N device states to disk" messages
Permission Errors¶
Problem: Cannot write to storage directory
Solutions:
1. Check directory permissions: ls -la ~/.lifx-emulator
2. Use custom directory with proper permissions
3. Run with appropriate user permissions
Corrupted State Files¶
Problem: Invalid JSON or deserialization errors
Solutions:
# Validate and repair
import json
from pathlib import Path
storage_dir = Path.home() / ".lifx-emulator"
for file_path in storage_dir.glob("*.json"):
try:
with open(file_path) as f:
json.load(f)
print(f"✓ {file_path.name}")
except json.JSONDecodeError:
print(f"✗ {file_path.name} - CORRUPTED")
# Delete or repair
file_path.unlink()
References¶
Source Files:
- packages/lifx-emulator-core/src/lifx_emulator/devices/persistence.py - Async storage implementation (DevicePersistenceAsyncFile)
- packages/lifx-emulator-core/src/lifx_emulator/devices/state_serializer.py - State serialisation
- packages/lifx-emulator-core/src/lifx_emulator/devices/state_restorer.py - Restoring saved state into new devices
Related Documentation:
- Device API - Device state structure
- CLI Reference - CLI flags reference
- Configuration File Guide - Recommended replacement for --persistent
See Also: - Persistent storage is optional and disabled by default - Storage uses standard JSON format for easy inspection and editing - State files can be manually edited (stop emulator first) - Storage directory can be version controlled for test fixtures