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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user