Add transient preview WS endpoint and test button in CSS editor modal

- Add /color-strip-sources/preview/ws endpoint for ad-hoc source preview
  without saving (accepts full config JSON, streams RGB frames)
- Add test preview button (flask icon) to CSS editor modal footer
- For self-contained types (static, gradient, color_cycle, effect, daylight,
  candlelight), always previews current form values via transient WS
- For non-previewable types, falls back to saved source test endpoint
- Fix route ordering: preview/ws registered before {source_id}/ws
- Fix css-test-led-control label alignment (display: inline globally)
- Add gradient onChange callback for future live-update support
- Add i18n keys for preview (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 14:49:22 +03:00
parent 6c7b7ea7d7
commit 014b4175b9
9 changed files with 281 additions and 3 deletions

View File

@@ -488,6 +488,186 @@ async def os_notification_history(_auth: AuthRequired):
}
# ── Transient Preview WebSocket ────────────────────────────────────────
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"}
@router.websocket("/api/v1/color-strip-sources/preview/ws")
async def preview_color_strip_ws(
websocket: WebSocket,
token: str = Query(""),
led_count: int = Query(100),
fps: int = Query(20),
):
"""Transient preview WebSocket — stream frames for an ad-hoc source config.
Auth via ``?token=<api_key>&led_count=100&fps=20``.
After accepting, waits for a text message containing the full source config
JSON (must include ``source_type``). Responds with a JSON metadata message,
then streams binary RGB frames at the requested FPS.
Subsequent text messages are treated as config updates: if the source_type
changed the old stream is replaced; otherwise ``update_source()`` is used.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
led_count = max(1, min(1000, led_count))
fps = max(1, min(60, fps))
frame_interval = 1.0 / fps
stream = None
clock_id = None
current_source_type = None
# Helpers ────────────────────────────────────────────────────────────
def _get_sync_clock_manager():
"""Return the SyncClockManager if available."""
try:
mgr = get_processor_manager()
return getattr(mgr, "_sync_clock_manager", None)
except Exception:
return None
def _build_source(config: dict):
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
from wled_controller.storage.color_strip_source import ColorStripSource
config.setdefault("id", "__preview__")
config.setdefault("name", "__preview__")
return ColorStripSource.from_dict(config)
def _create_stream(source):
"""Instantiate and start the appropriate stream class for *source*."""
from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
s = stream_cls(source)
if hasattr(s, "configure"):
s.configure(led_count)
# Inject sync clock if requested
cid = getattr(source, "clock_id", None)
if cid and hasattr(s, "set_clock"):
scm = _get_sync_clock_manager()
if scm:
try:
clock_rt = scm.acquire(cid)
s.set_clock(clock_rt)
except Exception as e:
logger.warning(f"Preview: could not acquire clock {cid}: {e}")
cid = None
else:
cid = None
else:
cid = None
s.start()
return s, cid
def _stop_stream(s, cid):
"""Stop a stream and release its clock."""
try:
s.stop()
except Exception:
pass
if cid:
scm = _get_sync_clock_manager()
if scm:
try:
scm.release(cid)
except Exception:
pass
async def _send_meta(source_type: str):
meta = {"type": "meta", "led_count": led_count, "source_type": source_type}
await websocket.send_text(_json.dumps(meta))
# Wait for initial config ────────────────────────────────────────────
try:
initial_text = await websocket.receive_text()
except WebSocketDisconnect:
return
except Exception:
return
try:
config = _json.loads(initial_text)
source_type = config.get("source_type")
if source_type not in _PREVIEW_ALLOWED_TYPES:
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
await websocket.close(code=4003, reason="Invalid source_type")
return
source = _build_source(config)
stream, clock_id = _create_stream(source)
current_source_type = source_type
except Exception as e:
logger.error(f"Preview WS: bad initial config: {e}")
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
await websocket.close(code=4003, reason=str(e))
return
await _send_meta(current_source_type)
logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}")
# Frame loop ─────────────────────────────────────────────────────────
try:
while True:
# Non-blocking check for incoming config updates
try:
msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval)
except asyncio.TimeoutError:
msg = None
except WebSocketDisconnect:
break
if msg is not None:
try:
new_config = _json.loads(msg)
new_type = new_config.get("source_type")
if new_type not in _PREVIEW_ALLOWED_TYPES:
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
continue
new_source = _build_source(new_config)
if new_type != current_source_type:
# Source type changed — recreate stream
_stop_stream(stream, clock_id)
stream, clock_id = _create_stream(new_source)
current_source_type = new_type
else:
stream.update_source(new_source)
if hasattr(stream, "configure"):
stream.configure(led_count)
await _send_meta(current_source_type)
except Exception as e:
logger.warning(f"Preview WS: bad config update: {e}")
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
# Send frame
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
# Stream hasn't produced a frame yet — send black
await websocket.send_bytes(b'\x00' * led_count * 3)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Preview WS error: {e}")
finally:
if stream is not None:
_stop_stream(stream, clock_id)
logger.info("Preview WS disconnected")
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
async def css_api_input_ws(
websocket: WebSocket,