Files
ledgrab/server/src/ledgrab/storage/device_store.py
T
alexei.dolgolyov d3a6416a1d refactor(devices): per-provider typed configs (phases 1-4)
Phase 1 — DeviceConfig hierarchy (device_config.py):
- 17 @dataclass(frozen=True) subclasses (WLEDConfig, AdalightConfig, …) sharing
  BaseDeviceConfig; DeviceConfig = Union[all 17]
- Device.to_config() in device_store.py: single flat→typed dispatch point

Phase 2+3 — Typed provider signatures + call-site migration:
- ProviderDeps(device_store) frozen dataclass in led_client.py
- LEDDeviceProvider.create_client(config, *, deps) abstract signature
- create_led_client(config, *, deps) factory dispatches via config.device_type
- All 17 providers narrowed to their specific config type; drop kwargs.get()
- GroupLEDClient.connect() uses device.to_config() + create_led_client()
- wled_target_processor: replaced 21-field DeviceInfo unpacking with to_config()
  + dataclasses.replace(config, use_ddp=…) for DDP override
- device_test_mode: build typed config via to_config() + ProviderDeps
- Deleted DeviceInfo dataclass, _get_device_info(), _DEVICE_FIELD_DEFAULTS
- TargetContext: replaced get_device_info callback with is_test_mode_active

Phase 4 — Test migration:
- 47-case test suite in tests/core/devices/test_device_config.py (100% coverage)
- test_group_device.py TestGroupLEDClient migrated to GroupConfig + ProviderDeps
- Removed legacy keyword-arg init path from GroupLEDClient
2026-04-18 01:24:27 +03:00

552 lines
20 KiB
Python

"""Device storage using SQLite."""
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import DeviceConfig
logger = get_logger(__name__)
class Device:
"""Represents a WLED device configuration.
A device holds connection state and output settings.
Calibration, processing settings, and picture source assignments
now live on ColorStripSource and WledOutputTarget respectively.
"""
def __init__(
self,
device_id: str,
name: str,
url: str,
led_count: int,
enabled: bool = True,
device_type: str = "wled",
baud_rate: Optional[int] = None,
software_brightness: int = 255,
auto_shutdown: bool = False,
send_latency_ms: int = 0,
rgbw: bool = False,
zone_mode: str = "combined",
tags: Optional[List[str]] = None,
# DMX (Art-Net / sACN) fields
dmx_protocol: str = "artnet",
dmx_start_universe: int = 0,
dmx_start_channel: int = 1,
# ESP-NOW fields
espnow_peer_mac: str = "",
espnow_channel: int = 1,
# Philips Hue fields
hue_username: str = "",
hue_client_key: str = "",
hue_entertainment_group_id: str = "",
# SPI Direct fields
spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B",
# Razer Chroma fields
chroma_device_type: str = "chromalink",
# SteelSeries GameSense fields
gamesense_device_type: str = "keyboard",
# BLE controller fields (SP110E / Triones / Zengge / Govee)
ble_family: str = "",
ble_govee_key: str = "",
# Default color strip processing template
default_css_processing_template_id: str = "",
# Group device fields
group_device_ids: Optional[List[str]] = None,
group_mode: str = "sequence",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
self.id = device_id
self.name = name
self.url = url
self.led_count = led_count
self.enabled = enabled
self.device_type = device_type
self.baud_rate = baud_rate
self.software_brightness = software_brightness
self.auto_shutdown = auto_shutdown
self.send_latency_ms = send_latency_ms
self.rgbw = rgbw
self.zone_mode = zone_mode
self.tags = list(tags) if tags else []
self.dmx_protocol = dmx_protocol
self.dmx_start_universe = dmx_start_universe
self.dmx_start_channel = dmx_start_channel
self.espnow_peer_mac = espnow_peer_mac
self.espnow_channel = espnow_channel
self.hue_username = hue_username
self.hue_client_key = hue_client_key
self.hue_entertainment_group_id = hue_entertainment_group_id
self.spi_speed_hz = spi_speed_hz
self.spi_led_type = spi_led_type
self.chroma_device_type = chroma_device_type
self.gamesense_device_type = gamesense_device_type
self.ble_family = ble_family
self.ble_govee_key = ble_govee_key
self.default_css_processing_template_id = default_css_processing_template_id
self.group_device_ids = group_device_ids or []
self.group_mode = group_mode
self.created_at = created_at or datetime.now(timezone.utc)
self.updated_at = updated_at or datetime.now(timezone.utc)
def to_config(self) -> "DeviceConfig":
"""Return a typed, immutable config snapshot for this device.
This is the single place that maps the flat Device storage model to the
correct per-provider config subclass. test_mode_active is runtime state
(not stored) and always defaults to False here.
"""
from ledgrab.core.devices.device_config import (
AdalightConfig,
AmbiLEDConfig,
BLEConfig,
ChromaConfig,
DemoConfig,
DMXConfig,
ESPNowConfig,
GameSenseConfig,
GroupConfig,
HueConfig,
MockConfig,
MQTTConfig,
OpenRGBConfig,
SPIConfig,
USBHIDConfig,
WLEDConfig,
WSConfig,
)
base = dict(
device_id=self.id,
device_url=self.url,
led_count=self.led_count,
software_brightness=self.software_brightness,
auto_shutdown=self.auto_shutdown,
rgbw=self.rgbw,
)
dt = self.device_type
if dt == "wled":
return WLEDConfig(**base)
if dt == "adalight":
return AdalightConfig(**base, baud_rate=self.baud_rate)
if dt == "ambiled":
return AmbiLEDConfig(**base, baud_rate=self.baud_rate)
if dt == "dmx":
return DMXConfig(
**base,
dmx_protocol=self.dmx_protocol,
dmx_start_universe=self.dmx_start_universe,
dmx_start_channel=self.dmx_start_channel,
)
if dt == "espnow":
return ESPNowConfig(
**base,
baud_rate=self.baud_rate,
espnow_peer_mac=self.espnow_peer_mac,
espnow_channel=self.espnow_channel,
)
if dt == "hue":
return HueConfig(
**base,
hue_username=self.hue_username,
hue_client_key=self.hue_client_key,
hue_entertainment_group_id=self.hue_entertainment_group_id,
)
if dt == "spi":
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
if dt == "chroma":
return ChromaConfig(**base, chroma_device_type=self.chroma_device_type)
if dt == "gamesense":
return GameSenseConfig(**base, gamesense_device_type=self.gamesense_device_type)
if dt == "ble":
return BLEConfig(**base, ble_family=self.ble_family, ble_govee_key=self.ble_govee_key)
if dt == "group":
return GroupConfig(
**base,
group_mode=self.group_mode,
group_device_ids=list(self.group_device_ids),
)
if dt == "openrgb":
return OpenRGBConfig(**base, zone_mode=self.zone_mode)
if dt == "mock":
return MockConfig(**base, send_latency_ms=self.send_latency_ms)
if dt == "demo":
return DemoConfig(**base, send_latency_ms=self.send_latency_ms)
if dt == "mqtt":
return MQTTConfig(**base)
if dt == "ws":
return WSConfig(**base)
if dt == "usbhid":
return USBHIDConfig(**base)
raise ValueError(f"Unknown device type: {dt!r}")
def to_dict(self) -> dict:
"""Convert device to dictionary."""
d = {
"id": self.id,
"name": self.name,
"url": self.url,
"led_count": self.led_count,
"enabled": self.enabled,
"device_type": self.device_type,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
if self.baud_rate is not None:
d["baud_rate"] = self.baud_rate
if self.software_brightness != 255:
d["software_brightness"] = self.software_brightness
if self.auto_shutdown:
d["auto_shutdown"] = True
if self.send_latency_ms:
d["send_latency_ms"] = self.send_latency_ms
if self.rgbw:
d["rgbw"] = True
if self.zone_mode != "combined":
d["zone_mode"] = self.zone_mode
if self.tags:
d["tags"] = self.tags
if self.dmx_protocol != "artnet":
d["dmx_protocol"] = self.dmx_protocol
if self.dmx_start_universe != 0:
d["dmx_start_universe"] = self.dmx_start_universe
if self.dmx_start_channel != 1:
d["dmx_start_channel"] = self.dmx_start_channel
if self.espnow_peer_mac:
d["espnow_peer_mac"] = self.espnow_peer_mac
if self.espnow_channel != 1:
d["espnow_channel"] = self.espnow_channel
if self.hue_username:
d["hue_username"] = self.hue_username
if self.hue_client_key:
d["hue_client_key"] = self.hue_client_key
if self.hue_entertainment_group_id:
d["hue_entertainment_group_id"] = self.hue_entertainment_group_id
if self.spi_speed_hz != 800000:
d["spi_speed_hz"] = self.spi_speed_hz
if self.spi_led_type != "WS2812B":
d["spi_led_type"] = self.spi_led_type
if self.chroma_device_type != "chromalink":
d["chroma_device_type"] = self.chroma_device_type
if self.gamesense_device_type != "keyboard":
d["gamesense_device_type"] = self.gamesense_device_type
if self.ble_family:
d["ble_family"] = self.ble_family
if self.ble_govee_key:
d["ble_govee_key"] = self.ble_govee_key
if self.default_css_processing_template_id:
d["default_css_processing_template_id"] = self.default_css_processing_template_id
if self.group_device_ids:
d["group_device_ids"] = self.group_device_ids
if self.group_mode != "sequence":
d["group_mode"] = self.group_mode
return d
@classmethod
def from_dict(cls, data: dict) -> "Device":
"""Create device from dictionary."""
return cls(
device_id=data["id"],
name=data["name"],
url=data["url"],
led_count=data["led_count"],
enabled=data.get("enabled", True),
device_type=data.get("device_type", "wled"),
baud_rate=data.get("baud_rate"),
software_brightness=data.get("software_brightness", 255),
auto_shutdown=data.get("auto_shutdown", False),
send_latency_ms=data.get("send_latency_ms", 0),
rgbw=data.get("rgbw", False),
zone_mode=data.get("zone_mode", "combined"),
tags=data.get("tags", []),
dmx_protocol=data.get("dmx_protocol", "artnet"),
dmx_start_universe=data.get("dmx_start_universe", 0),
dmx_start_channel=data.get("dmx_start_channel", 1),
espnow_peer_mac=data.get("espnow_peer_mac", ""),
espnow_channel=data.get("espnow_channel", 1),
hue_username=data.get("hue_username", ""),
hue_client_key=data.get("hue_client_key", ""),
hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""),
spi_speed_hz=data.get("spi_speed_hz", 800000),
spi_led_type=data.get("spi_led_type", "WS2812B"),
chroma_device_type=data.get("chroma_device_type", "chromalink"),
gamesense_device_type=data.get("gamesense_device_type", "keyboard"),
ble_family=data.get("ble_family", ""),
ble_govee_key=data.get("ble_govee_key", ""),
default_css_processing_template_id=data.get("default_css_processing_template_id", ""),
group_device_ids=data.get("group_device_ids", []),
group_mode=data.get("group_mode", "sequence"),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
updated_at=datetime.fromisoformat(
data.get("updated_at", datetime.now(timezone.utc).isoformat())
),
)
# Fields that can be updated (all Device.__init__ params except identity/timestamps)
_UPDATABLE_FIELDS: frozenset[str] = frozenset(
{
"name",
"url",
"led_count",
"enabled",
"device_type",
"baud_rate",
"software_brightness",
"auto_shutdown",
"send_latency_ms",
"rgbw",
"zone_mode",
"tags",
"dmx_protocol",
"dmx_start_universe",
"dmx_start_channel",
"espnow_peer_mac",
"espnow_channel",
"hue_username",
"hue_client_key",
"hue_entertainment_group_id",
"spi_speed_hz",
"spi_led_type",
"chroma_device_type",
"gamesense_device_type",
"ble_family",
"ble_govee_key",
"default_css_processing_template_id",
"group_device_ids",
"group_mode",
}
)
class DeviceStore(BaseSqliteStore[Device]):
"""Persistent storage for WLED devices."""
_table_name = "devices"
_entity_name = "Device"
def __init__(self, db: Database):
super().__init__(db, Device.from_dict)
logger.info(f"Device store initialized with {len(self._items)} devices")
# ── Backward-compat aliases (thin re-export of base methods) ─
get_device = BaseSqliteStore.get
get_all_devices = BaseSqliteStore.get_all
delete_device = BaseSqliteStore.delete
# ── Create / Update ──────────────────────────────────────────
def create_device(
self,
name: str,
url: str,
led_count: int,
device_type: str = "wled",
baud_rate: Optional[int] = None,
auto_shutdown: bool = False,
send_latency_ms: int = 0,
rgbw: bool = False,
zone_mode: str = "combined",
tags: Optional[List[str]] = None,
dmx_protocol: str = "artnet",
dmx_start_universe: int = 0,
dmx_start_channel: int = 1,
espnow_peer_mac: str = "",
espnow_channel: int = 1,
hue_username: str = "",
hue_client_key: str = "",
hue_entertainment_group_id: str = "",
spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink",
gamesense_device_type: str = "keyboard",
ble_family: str = "",
ble_govee_key: str = "",
group_device_ids: Optional[List[str]] = None,
group_mode: str = "sequence",
) -> Device:
"""Create a new device."""
with self._lock:
self._check_name_unique(name)
device_id = f"device_{uuid.uuid4().hex[:8]}"
# Mock devices use their device ID as the URL authority
if device_type == "mock":
url = f"mock://{device_id}"
device = Device(
device_id=device_id,
name=name,
url=url,
led_count=led_count,
device_type=device_type,
baud_rate=baud_rate,
auto_shutdown=auto_shutdown,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
zone_mode=zone_mode,
tags=tags or [],
dmx_protocol=dmx_protocol,
dmx_start_universe=dmx_start_universe,
dmx_start_channel=dmx_start_channel,
espnow_peer_mac=espnow_peer_mac,
espnow_channel=espnow_channel,
hue_username=hue_username,
hue_client_key=hue_client_key,
hue_entertainment_group_id=hue_entertainment_group_id,
spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type,
gamesense_device_type=gamesense_device_type,
ble_family=ble_family,
ble_govee_key=ble_govee_key,
group_device_ids=group_device_ids or [],
group_mode=group_mode,
)
self._items[device_id] = device
self._save_item(device_id, device)
logger.info(f"Created device {device_id}: {name}")
return device
def update_device(self, device_id: str, **kwargs) -> Device:
"""Update device fields.
Pass any updatable Device field as a keyword argument.
``None`` values are ignored (no change).
"""
with self._lock:
device = self.get(device_id) # raises ValueError if not found
# Collect updates (ignore None values and unknown fields)
updates = {
key: value
for key, value in kwargs.items()
if value is not None and key in _UPDATABLE_FIELDS
}
# Check name uniqueness if name is being changed
new_name = updates.get("name")
if new_name is not None and new_name != device.name:
self._check_name_unique(new_name, exclude_id=device_id)
# Build new Device from existing fields + updates (immutable pattern)
device_fields = device.to_dict()
# Map 'id' back to 'device_id' for the constructor
device_fields["device_id"] = device_fields.pop("id")
# Restore datetime objects (to_dict serializes them as ISO strings)
device_fields["created_at"] = device.created_at
device_fields["updated_at"] = datetime.now(timezone.utc)
# Apply updates
device_fields.update(updates)
new_device = Device(**device_fields)
self._items[device_id] = new_device
self._save_item(device_id, new_device)
logger.info(f"Updated device {device_id}")
return new_device
# ── Unique helpers ───────────────────────────────────────────
def device_exists(self, device_id: str) -> bool:
"""Check if device exists."""
return device_id in self._items
# ── Group helpers ───────────────────────────────────────────
def validate_group_no_cycles(
self,
device_id: Optional[str],
group_device_ids: List[str],
) -> None:
"""Raise ValueError if adding these children would create a cycle.
Uses DFS with backtracking — diamond DAGs (A→B, A→C, B→D, C→D) are
allowed; only true cycles are rejected.
"""
ancestors = set()
if device_id:
ancestors.add(device_id)
def _dfs(child_ids: List[str]) -> None:
for cid in child_ids:
if cid in ancestors:
raise ValueError(f"Circular group reference detected: {cid}")
device = self._items.get(cid)
if device is None:
raise ValueError(f"Referenced device not found: {cid}")
if device.group_device_ids:
ancestors.add(cid)
_dfs(device.group_device_ids)
ancestors.discard(cid)
_dfs(group_device_ids)
def resolve_group_led_count(
self,
device_ids: List[str],
_seen: Optional[set] = None,
) -> int:
"""Sum led_counts of devices, recursively resolving nested sequence groups."""
if _seen is None:
_seen = set()
total = 0
for did in device_ids:
if did in _seen:
continue
_seen.add(did)
device = self._items.get(did)
if device is None:
continue
if (
device.device_type == "group"
and device.group_mode == "sequence"
and device.group_device_ids
):
total += self.resolve_group_led_count(device.group_device_ids, _seen)
else:
total += device.led_count
return total
def resolve_group_max_led_count(
self,
device_ids: List[str],
) -> int:
"""Return max led_count among children (for independent mode default)."""
max_count = 0
for did in device_ids:
device = self._items.get(did)
if device is None:
continue
max_count = max(max_count, device.led_count)
return max_count or 1
def get_groups_referencing(self, device_id: str) -> List["Device"]:
"""Return all group devices whose group_device_ids contains device_id."""
return [
d
for d in self._items.values()
if d.device_type == "group" and device_id in d.group_device_ids
]
def clear(self):
"""Clear all devices (for testing)."""
self._items.clear()
self._db.delete_all(self._table_name)
logger.warning("Cleared all devices from storage")