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>
This commit is contained in:
@@ -6,7 +6,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -52,6 +53,8 @@ class Device:
|
||||
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,
|
||||
):
|
||||
@@ -80,6 +83,7 @@ class Device:
|
||||
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)
|
||||
|
||||
@@ -133,6 +137,8 @@ class Device:
|
||||
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
|
||||
@@ -164,78 +170,44 @@ class Device:
|
||||
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())),
|
||||
)
|
||||
|
||||
|
||||
class DeviceStore:
|
||||
# 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):
|
||||
self.storage_file = Path(storage_file)
|
||||
self._devices: Dict[str, Device] = {}
|
||||
super().__init__(file_path=str(storage_file), deserializer=Device.from_dict)
|
||||
logger.info(f"Device store initialized with {len(self._items)} devices")
|
||||
|
||||
# Ensure directory exists
|
||||
self.storage_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ── Backward-compat aliases ──────────────────────────────────
|
||||
|
||||
# Load existing devices
|
||||
self.load()
|
||||
def get_device(self, device_id: str) -> Device:
|
||||
"""Get device by ID. Raises ValueError if not found."""
|
||||
return self.get(device_id)
|
||||
|
||||
logger.info(f"Device store initialized with {len(self._devices)} devices")
|
||||
def get_all_devices(self) -> List[Device]:
|
||||
"""Get all devices."""
|
||||
return self.get_all()
|
||||
|
||||
def load(self):
|
||||
"""Load devices from storage file."""
|
||||
if not self.storage_file.exists():
|
||||
logger.info("Storage file does not exist, starting with empty store")
|
||||
return
|
||||
def delete_device(self, device_id: str) -> None:
|
||||
"""Delete device. Raises ValueError if not found."""
|
||||
self.delete(device_id)
|
||||
|
||||
try:
|
||||
with open(self.storage_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
devices_data = data.get("devices", {})
|
||||
self._devices = {
|
||||
device_id: Device.from_dict(device_data)
|
||||
for device_id, device_data in devices_data.items()
|
||||
}
|
||||
|
||||
logger.info(f"Loaded {len(self._devices)} devices from storage")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse storage file: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load devices: {e}")
|
||||
raise
|
||||
|
||||
def load_raw(self) -> dict:
|
||||
"""Load raw JSON data from storage (for migration)."""
|
||||
if not self.storage_file.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(self.storage_file, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save(self):
|
||||
"""Save devices to storage file."""
|
||||
try:
|
||||
data = {
|
||||
"devices": {
|
||||
device_id: device.to_dict()
|
||||
for device_id, device in self._devices.items()
|
||||
}
|
||||
}
|
||||
|
||||
atomic_write_json(self.storage_file, data)
|
||||
|
||||
logger.debug(f"Saved {len(self._devices)} devices to storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save devices: {e}")
|
||||
raise
|
||||
# ── Create / Update ──────────────────────────────────────────
|
||||
|
||||
def create_device(
|
||||
self,
|
||||
@@ -295,122 +267,48 @@ class DeviceStore:
|
||||
gamesense_device_type=gamesense_device_type,
|
||||
)
|
||||
|
||||
self._devices[device_id] = device
|
||||
self.save()
|
||||
self._items[device_id] = device
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created device {device_id}: {name}")
|
||||
return device
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[Device]:
|
||||
"""Get device by ID."""
|
||||
return self._devices.get(device_id)
|
||||
def update_device(self, device_id: str, **kwargs) -> Device:
|
||||
"""Update device fields.
|
||||
|
||||
def get_all_devices(self) -> List[Device]:
|
||||
"""Get all devices."""
|
||||
return list(self._devices.values())
|
||||
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
|
||||
|
||||
def update_device(
|
||||
self,
|
||||
device_id: str,
|
||||
name: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
led_count: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
baud_rate: Optional[int] = None,
|
||||
auto_shutdown: Optional[bool] = None,
|
||||
send_latency_ms: Optional[int] = None,
|
||||
rgbw: Optional[bool] = None,
|
||||
zone_mode: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
dmx_protocol: Optional[str] = None,
|
||||
dmx_start_universe: Optional[int] = None,
|
||||
dmx_start_channel: Optional[int] = None,
|
||||
espnow_peer_mac: Optional[str] = None,
|
||||
espnow_channel: Optional[int] = None,
|
||||
hue_username: Optional[str] = None,
|
||||
hue_client_key: Optional[str] = None,
|
||||
hue_entertainment_group_id: Optional[str] = None,
|
||||
spi_speed_hz: Optional[int] = None,
|
||||
spi_led_type: Optional[str] = None,
|
||||
chroma_device_type: Optional[str] = None,
|
||||
gamesense_device_type: Optional[str] = None,
|
||||
) -> Device:
|
||||
"""Update device."""
|
||||
device = self._devices.get(device_id)
|
||||
if not device:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
if name is not None:
|
||||
device.name = name
|
||||
if url is not None:
|
||||
device.url = url
|
||||
if led_count is not None:
|
||||
device.led_count = led_count
|
||||
if enabled is not None:
|
||||
device.enabled = enabled
|
||||
if baud_rate is not None:
|
||||
device.baud_rate = baud_rate
|
||||
if auto_shutdown is not None:
|
||||
device.auto_shutdown = auto_shutdown
|
||||
if send_latency_ms is not None:
|
||||
device.send_latency_ms = send_latency_ms
|
||||
if rgbw is not None:
|
||||
device.rgbw = rgbw
|
||||
if zone_mode is not None:
|
||||
device.zone_mode = zone_mode
|
||||
if tags is not None:
|
||||
device.tags = tags
|
||||
if dmx_protocol is not None:
|
||||
device.dmx_protocol = dmx_protocol
|
||||
if dmx_start_universe is not None:
|
||||
device.dmx_start_universe = dmx_start_universe
|
||||
if dmx_start_channel is not None:
|
||||
device.dmx_start_channel = dmx_start_channel
|
||||
if espnow_peer_mac is not None:
|
||||
device.espnow_peer_mac = espnow_peer_mac
|
||||
if espnow_channel is not None:
|
||||
device.espnow_channel = espnow_channel
|
||||
if hue_username is not None:
|
||||
device.hue_username = hue_username
|
||||
if hue_client_key is not None:
|
||||
device.hue_client_key = hue_client_key
|
||||
if hue_entertainment_group_id is not None:
|
||||
device.hue_entertainment_group_id = hue_entertainment_group_id
|
||||
if spi_speed_hz is not None:
|
||||
device.spi_speed_hz = spi_speed_hz
|
||||
if spi_led_type is not None:
|
||||
device.spi_led_type = spi_led_type
|
||||
if chroma_device_type is not None:
|
||||
device.chroma_device_type = chroma_device_type
|
||||
if gamesense_device_type is not None:
|
||||
device.gamesense_device_type = gamesense_device_type
|
||||
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()
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated device {device_id}")
|
||||
return device
|
||||
|
||||
def delete_device(self, device_id: str):
|
||||
"""Delete device."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
del self._devices[device_id]
|
||||
self.save()
|
||||
|
||||
logger.info(f"Deleted device {device_id}")
|
||||
# ── Unique helpers ───────────────────────────────────────────
|
||||
|
||||
def device_exists(self, device_id: str) -> bool:
|
||||
"""Check if device exists."""
|
||||
return device_id in self._devices
|
||||
|
||||
def count(self) -> int:
|
||||
"""Get number of devices."""
|
||||
return len(self._devices)
|
||||
return device_id in self._items
|
||||
|
||||
def clear(self):
|
||||
"""Clear all devices (for testing)."""
|
||||
self._devices.clear()
|
||||
self.save()
|
||||
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 {}
|
||||
|
||||
Reference in New Issue
Block a user