d3a6416a1d
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
552 lines
20 KiB
Python
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")
|