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:
2026-03-15 02:16:59 +03:00
parent 7e78323c9c
commit 294d704eb0
72 changed files with 2992 additions and 1416 deletions

View File

@@ -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 {}