Files
wled-screen-controller-mixed/server/src/wled_controller/storage/device_store.py
alexei.dolgolyov 294d704eb0 Add CSPT entity, processed CSS source type, reverse filter, and UI improvements
- Add Color Strip Processing Template (CSPT) entity: reusable filter chains
  for 1D LED strip postprocessing (backend, storage, API, frontend CRUD)
- Add "processed" color strip source type that wraps another CSS source and
  applies a CSPT filter chain (dataclass, stream, schema, modal, cards)
- Add Reverse filter for strip LED order reversal
- Add CSPT and processed CSS nodes/edges to visual graph editor
- Add CSPT test preview WS endpoint with input source selection
- Add device settings CSPT template selector (add + edit modals with hints)
- Use icon grids for palette quantization preset selector in filter lists
- Use EntitySelect for template references and test modal source selectors
- Fix filters.css_filter_template.desc missing localization
- Fix icon grid cell height inequality (grid-auto-rows: 1fr)
- Rename "Processed" subtab to "Processing Templates"
- Localize all new strings (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 02:16:59 +03:00

315 lines
12 KiB
Python

"""Device storage using JSON files."""
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.utils import get_logger
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: 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",
# Default color strip processing template
default_css_processing_template_id: str = "",
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 = tags or []
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.default_css_processing_template_id = default_css_processing_template_id
self.created_at = created_at or datetime.now(timezone.utc)
self.updated_at = updated_at or datetime.now(timezone.utc)
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.default_css_processing_template_id:
d["default_css_processing_template_id"] = self.default_css_processing_template_id
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"),
default_css_processing_template_id=data.get("default_css_processing_template_id", ""),
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 = {
k for k in Device.__init__.__code__.co_varnames
if k not in ('self', 'device_id', 'created_at', 'updated_at')
}
class DeviceStore(BaseJsonStore[Device]):
"""Persistent storage for WLED devices."""
_json_key = "devices"
_entity_name = "Device"
def __init__(self, storage_file: str | Path):
super().__init__(file_path=str(storage_file), deserializer=Device.from_dict)
logger.info(f"Device store initialized with {len(self._items)} devices")
# ── Backward-compat aliases ──────────────────────────────────
def get_device(self, device_id: str) -> Device:
"""Get device by ID. Raises ValueError if not found."""
return self.get(device_id)
def get_all_devices(self) -> List[Device]:
"""Get all devices."""
return self.get_all()
def delete_device(self, device_id: str) -> None:
"""Delete device. Raises ValueError if not found."""
self.delete(device_id)
# ── 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",
) -> Device:
"""Create a new device."""
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,
)
self._items[device_id] = device
self._save()
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).
"""
device = self.get(device_id) # raises ValueError if not found
for key, value in kwargs.items():
if value is not None and key in _UPDATABLE_FIELDS:
setattr(device, key, value)
device.updated_at = datetime.now(timezone.utc)
self._save()
logger.info(f"Updated device {device_id}")
return device
# ── Unique helpers ───────────────────────────────────────────
def device_exists(self, device_id: str) -> bool:
"""Check if device exists."""
return device_id in self._items
def clear(self):
"""Clear all devices (for testing)."""
self._items.clear()
self._save()
logger.warning("Cleared all devices from storage")
def load_raw(self) -> dict:
"""Load raw JSON data from storage (for migration)."""
if not self.file_path.exists():
return {}
try:
with open(self.file_path, "r") as f:
return json.load(f)
except Exception:
return {}