refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s

Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
This commit is contained in:
2026-03-22 00:38:28 +03:00
parent 07bb89e9b7
commit f2871319cb
115 changed files with 9808 additions and 5818 deletions

View File

@@ -3,12 +3,16 @@
from fastapi import APIRouter
from .routes.system import router as system_router
from .routes.backup import router as backup_router
from .routes.system_settings import router as system_settings_router
from .routes.devices import router as devices_router
from .routes.templates import router as templates_router
from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.output_targets import router as output_targets_router
from .routes.output_targets_control import router as output_targets_control_router
from .routes.output_targets_keycolors import router as output_targets_keycolors_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_router
@@ -22,6 +26,8 @@ from .routes.color_strip_processing import router as cspt_router
router = APIRouter()
router.include_router(system_router)
router.include_router(backup_router)
router.include_router(system_settings_router)
router.include_router(devices_router)
router.include_router(templates_router)
router.include_router(postprocessing_router)
@@ -33,6 +39,8 @@ router.include_router(audio_sources_router)
router.include_router(audio_templates_router)
router.include_router(value_sources_router)
router.include_router(output_targets_router)
router.include_router(output_targets_control_router)
router.include_router(output_targets_keycolors_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(webhooks_router)

View File

@@ -1,4 +1,4 @@
"""Shared helpers for WebSocket-based capture test endpoints."""
"""Shared helpers for WebSocket-based capture preview endpoints."""
import asyncio
import base64
@@ -50,7 +50,7 @@ def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int
scale = max_width / image.shape[1]
new_h = int(image.shape[0] * scale)
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA)
# RGB BGR for OpenCV JPEG encoding
# RGB -> BGR for OpenCV JPEG encoding
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
return buf.tobytes()
@@ -124,7 +124,7 @@ async def stream_capture_test(
continue
total_capture_time += t1 - t0
frame_count += 1
# Convert numpy PIL once in the capture thread
# Convert numpy -> PIL once in the capture thread
if isinstance(capture.image, np.ndarray):
latest_frame = Image.fromarray(capture.image)
else:

View File

@@ -0,0 +1,395 @@
"""System routes: backup, restore, export, import, auto-backup.
Extracted from system.py to keep files under 800 lines.
"""
import asyncio
import io
import json
import subprocess
import sys
import threading
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_auto_backup_engine
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
RestoreResponse,
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Configuration backup / restore
# ---------------------------------------------------------------------------
# Mapping: logical store name -> StorageConfig attribute name
STORE_MAP = {
"devices": "devices_file",
"capture_templates": "templates_file",
"postprocessing_templates": "postprocessing_templates_file",
"picture_sources": "picture_sources_file",
"output_targets": "output_targets_file",
"pattern_templates": "pattern_templates_file",
"color_strip_sources": "color_strip_sources_file",
"audio_sources": "audio_sources_file",
"audio_templates": "audio_templates_file",
"value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"color_strip_processing_templates": "color_strip_processing_templates_file",
"automations": "automations_file",
"scene_presets": "scene_presets_file",
}
_SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes."""
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
["bash", str(_SERVER_DIR / "restart.sh")],
start_new_session=True,
)
threading.Thread(target=_restart, daemon=True).start()
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
def export_store(store_key: str, _: AuthRequired):
"""Download a single entity store as a JSON file."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
export = {
"meta": {
"format": "ledgrab-partial-export",
"format_version": 1,
"store_key": store_key,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
},
"store": data,
}
content = json.dumps(export, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-{store_key}-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
async def import_store(
store_key: str,
_: AuthRequired,
file: UploadFile = File(...),
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
):
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Support both full-backup format and partial-export format
if "stores" in payload and isinstance(payload.get("meta"), dict):
# Full backup: extract the specific store
if payload["meta"].get("format") not in ("ledgrab-backup",):
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
stores = payload.get("stores", {})
if store_key not in stores:
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
incoming = stores[store_key]
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
# Partial export format
if payload["meta"].get("store_key") != store_key:
raise HTTPException(
status_code=400,
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
)
incoming = payload.get("store", {})
else:
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
if not isinstance(incoming, dict):
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
def _write():
if merge and file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict):
existing.update(incoming)
atomic_write_json(file_path, existing)
return len(existing)
atomic_write_json(file_path, incoming)
return len(incoming)
count = await asyncio.to_thread(_write)
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
"status": "imported",
"store_key": store_key,
"entries": count,
"merge": merge,
"restart_scheduled": True,
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
}
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired):
"""Download all configuration as a single JSON backup file."""
config = get_config()
stores = {}
for store_key, config_attr in STORE_MAP.items():
file_path = Path(getattr(config.storage, config_attr))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
stores[store_key] = json.load(f)
else:
stores[store_key] = {}
backup = {
"meta": {
"format": "ledgrab-backup",
"format_version": 1,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
"store_count": len(stores),
},
"stores": stores,
}
content = json.dumps(backup, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config(
_: AuthRequired,
file: UploadFile = File(...),
):
"""Upload a backup file to restore all configuration. Triggers server restart."""
# Read and parse
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
backup = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
# Validate envelope
meta = backup.get("meta")
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
fmt_version = meta.get("format_version", 0)
if fmt_version > 1:
raise HTTPException(
status_code=400,
detail=f"Backup format version {fmt_version} is not supported by this server version",
)
stores = backup.get("stores")
if not isinstance(stores, dict):
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
known_keys = set(STORE_MAP.keys())
present_keys = known_keys & set(stores.keys())
if not present_keys:
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
for key in present_keys:
if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
# Write store files atomically (in thread to avoid blocking event loop)
config = get_config()
def _write_stores():
count = 0
for store_key, config_attr in STORE_MAP.items():
if store_key in stores:
file_path = Path(getattr(config.storage, config_attr))
atomic_write_json(file_path, stores[store_key])
count += 1
logger.info(f"Restored store: {store_key} -> {file_path}")
return count
written = await asyncio.to_thread(_write_stores)
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart()
missing = known_keys - present_keys
return RestoreResponse(
status="restored",
stores_written=written,
stores_total=len(STORE_MAP),
missing_stores=sorted(missing) if missing else [],
restart_scheduled=True,
message=f"Restored {written} stores. Server restarting...",
)
# ---------------------------------------------------------------------------
# Auto-backup settings & saved backups
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def get_auto_backup_settings(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Get auto-backup settings and status."""
return engine.get_settings()
@router.put(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def update_auto_backup_settings(
_: AuthRequired,
body: AutoBackupSettings,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
async def trigger_backup(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Manually trigger a backup now."""
backup = await engine.trigger_backup()
return {"status": "ok", "backup": backup}
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
tags=["System"],
)
async def list_backups(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""List all saved backup files."""
backups = engine.list_backups()
return BackupListResponse(
backups=[BackupFileInfo(**b) for b in backups],
count=len(backups),
)
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
def download_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Download a specific saved backup file."""
try:
path = engine.get_backup_path(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
content = path.read_bytes()
return StreamingResponse(
io.BytesIO(content),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
async def delete_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Delete a specific saved backup file."""
try:
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "deleted", "filename": filename}

View File

@@ -16,6 +16,7 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.devices import (
BrightnessRequest,
DeviceCreate,
DeviceListResponse,
DeviceResponse,
@@ -25,6 +26,7 @@ from wled_controller.api.schemas.devices import (
DiscoverDevicesResponse,
OpenRGBZoneResponse,
OpenRGBZonesResponse,
PowerRequest,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
@@ -53,18 +55,19 @@ def _device_to_response(device) -> DeviceResponse:
zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)),
tags=device.tags,
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),
espnow_peer_mac=getattr(device, 'espnow_peer_mac', ''),
espnow_channel=getattr(device, 'espnow_channel', 1),
hue_username=getattr(device, 'hue_username', ''),
hue_client_key=getattr(device, 'hue_client_key', ''),
hue_entertainment_group_id=getattr(device, 'hue_entertainment_group_id', ''),
spi_speed_hz=getattr(device, 'spi_speed_hz', 800000),
spi_led_type=getattr(device, 'spi_led_type', 'WS2812B'),
chroma_device_type=getattr(device, 'chroma_device_type', 'chromalink'),
gamesense_device_type=getattr(device, 'gamesense_device_type', 'keyboard'),
dmx_protocol=device.dmx_protocol,
dmx_start_universe=device.dmx_start_universe,
dmx_start_channel=device.dmx_start_channel,
espnow_peer_mac=device.espnow_peer_mac,
espnow_channel=device.espnow_channel,
hue_username=device.hue_username,
hue_client_key=device.hue_client_key,
hue_entertainment_group_id=device.hue_entertainment_group_id,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
gamesense_device_type=device.gamesense_device_type,
default_css_processing_template_id=device.default_css_processing_template_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -508,7 +511,7 @@ async def get_device_brightness(
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def set_device_brightness(
device_id: str,
body: dict,
body: BrightnessRequest,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
@@ -521,9 +524,7 @@ async def set_device_brightness(
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
bri = body.get("brightness")
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
bri = body.brightness
try:
try:
@@ -581,7 +582,7 @@ async def get_device_power(
@router.put("/api/v1/devices/{device_id}/power", tags=["Settings"])
async def set_device_power(
device_id: str,
body: dict,
body: PowerRequest,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
@@ -594,9 +595,7 @@ async def set_device_power(
if "power_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
on = body.get("on")
if on is None or not isinstance(on, bool):
raise HTTPException(status_code=400, detail="'on' must be a boolean")
on = body.power
try:
# For serial devices, use the cached idle client to avoid port conflicts

View File

@@ -1,57 +1,26 @@
"""Output target routes: CRUD, processing control, settings, state, metrics."""
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
import base64
import io
import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends
from fastapi import Query as QueryParam
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_pattern_template_store,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse,
KeyColorsSettingsSchema,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
OutputTargetUpdate,
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
get_available_displays,
)
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings,
@@ -326,7 +295,7 @@ async def update_target(
except ValueError:
pass
# Device change requires async stop swap start cycle
# Device change requires async stop -> swap -> start cycle
if data.device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
@@ -377,795 +346,3 @@ async def delete_target(
except Exception as e:
logger.error(f"Failed to delete target: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_start_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
started: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
errors[target_id] = msg
except Exception as e:
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
stopped: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(stopped=stopped, errors=errors)
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
async def start_processing(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for a output target."""
try:
# Verify target exists in store
target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
# Resolve target IDs to human-readable names in error messages
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
raise HTTPException(status_code=409, detail=msg)
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
async def stop_processing(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a output target."""
try:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
async def get_target_state(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a target."""
try:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
async def get_target_metrics(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a target."""
try:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime, timezone
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.now(timezone.utc),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
async def test_kc_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None
try:
# 1. Load and validate KC target
try:
target = target_store.get_target(target_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if not isinstance(target, KeyColorsOutputTarget):
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
settings = target.settings
# 2. Resolve pattern template
if not settings.pattern_template_id:
raise HTTPException(status_code=400, detail="No pattern template configured")
try:
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
rectangles = pattern_tmpl.rectangles
if not rectangles:
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
# 3. Resolve picture source and capture a frame
if not target.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB")
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {raw_stream.capture_template_id}",
)
display_index = raw_stream.display_index
if capture_template.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
)
locked_device_id = processor_manager.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
f"Please stop the device processing before testing.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
screen_capture = stream.capture_frame()
if screen_capture is None:
raise RuntimeError("No frame captured")
if isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
raise ValueError("Unexpected image format from engine")
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store.get_template(pp_id)
except ValueError:
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
continue
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
pil_image = Image.fromarray(img_array)
# 4. Extract colors from each rectangle
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append(KCTestRectangleResponse(
name=rect.name,
x=rect.x,
y=rect.y,
width=rect.width,
height=rect.height,
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
))
# 5. Encode frame as base64 JPEG
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
return KCTestResponse(
image=image_data_uri,
rectangles=result_rects,
interpolation_mode=settings.interpolation_mode,
pattern_template_name=pattern_tmpl.name,
)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
if stream:
try:
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
template_store_inst: TemplateStore = get_template_store()
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
processor_manager_inst: ProcessorManager = get_processor_manager()
device_store_inst: DeviceStore = get_device_store()
pp_template_store_inst = get_pp_template_store()
# Validate target
try:
target = target_store_inst.get_target(target_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(target, KeyColorsOutputTarget):
await websocket.close(code=4003, reason="Target is not a key_colors target")
return
settings = target.settings
if not settings.pattern_template_id:
await websocket.close(code=4003, reason="No pattern template configured")
return
try:
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
except ValueError:
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
return
rectangles = pattern_tmpl.rectangles
if not rectangles:
await websocket.close(code=4003, reason="Pattern template has no rectangles")
return
if not target.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
# For screen capture sources, check display lock
if isinstance(raw_stream, ScreenCapturePictureSource):
display_index = raw_stream.display_index
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store_inst.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
await websocket.close(
code=4003,
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
)
return
fps = max(1, min(30, fps))
preview_width = max(120, min(1920, preview_width))
frame_interval = 1.0 / fps
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
# Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True:
loop_start = time.monotonic()
try:
capture = await asyncio.to_thread(live_stream.get_latest_frame)
if capture is None or capture.image is None:
await asyncio.sleep(frame_interval)
continue
# Skip if same frame object (no new capture yet)
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
if pil_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError:
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
pass
pil_image = Image.fromarray(img_array)
# Extract colors
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append({
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
})
# Encode frame as JPEG
if preview_width and pil_image.width > preview_width:
ratio = preview_width / pil_image.width
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
else:
thumb = pil_image
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
await websocket.send_text(_json.dumps({
"type": "frame",
"image": f"data:image/jpeg;base64,{b64}",
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
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()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
async def led_preview_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
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()
manager = get_processor_manager()
try:
manager.add_led_preview_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_led_preview_client(target_id, websocket)
# ===== STATE CHANGE EVENT STREAM =====
@router.websocket("/api/v1/events/ws")
async def events_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
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()
manager = get_processor_manager()
queue = manager.subscribe_events()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
manager.unsubscribe_events(queue)
# ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
async def start_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
target_store: OutputTargetStore = Depends(get_output_target_store),
color_strip_store: ColorStripStore = Depends(get_color_strip_store),
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Start screen overlay visualization for a target.
Displays a transparent overlay on the target display showing:
- Border sampling zones (colored rectangles)
- LED position markers (numbered dots)
- Pixel-to-LED mapping ranges (colored segments)
- Calibration info text
"""
try:
# Get target name from store
target = target_store.get_target(target_id)
if not target:
raise ValueError(f"Target {target_id} not found")
# Pre-load calibration and display info from the CSS store so the overlay
# can start even when processing is not currently running.
calibration = None
display_info = None
if isinstance(target, WledOutputTarget) and target.color_strip_source_id:
first_css_id = target.color_strip_source_id
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
ps_id = getattr(css, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays()
if displays:
display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index]
except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
async def stop_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop screen overlay visualization for a target."""
try:
await manager.stop_overlay(target_id)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
async def get_overlay_status(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Check if overlay is active for a target."""
try:
active = manager.is_overlay_active(target_id)
return {"target_id": target_id, "active": active}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,341 @@
"""Output target routes: processing control, state, metrics, events, overlay.
Extracted from output_targets.py to keep files under 800 lines.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_output_target_store,
get_picture_source_store,
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_start_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
started: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
errors[target_id] = msg
except Exception as e:
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
stopped: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(stopped=stopped, errors=errors)
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
async def start_processing(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for a output target."""
try:
# Verify target exists in store
target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
# Resolve target IDs to human-readable names in error messages
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
raise HTTPException(status_code=409, detail=msg)
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
async def stop_processing(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a output target."""
try:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
async def get_target_state(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a target."""
try:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
async def get_target_metrics(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a target."""
try:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== STATE CHANGE EVENT STREAM =====
@router.websocket("/api/v1/events/ws")
async def events_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
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()
manager = get_processor_manager()
queue = manager.subscribe_events()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
manager.unsubscribe_events(queue)
# ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
async def start_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
target_store: OutputTargetStore = Depends(get_output_target_store),
color_strip_store: ColorStripStore = Depends(get_color_strip_store),
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Start screen overlay visualization for a target.
Displays a transparent overlay on the target display showing:
- Border sampling zones (colored rectangles)
- LED position markers (numbered dots)
- Pixel-to-LED mapping ranges (colored segments)
- Calibration info text
"""
try:
# Get target name from store
target = target_store.get_target(target_id)
if not target:
raise ValueError(f"Target {target_id} not found")
# Pre-load calibration and display info from the CSS store so the overlay
# can start even when processing is not currently running.
calibration = None
display_info = None
if isinstance(target, WledOutputTarget) and target.color_strip_source_id:
first_css_id = target.color_strip_source_id
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
ps_id = getattr(css, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays()
if displays:
display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index]
except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
async def stop_target_overlay(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop screen overlay visualization for a target."""
try:
await manager.stop_overlay(target_id)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
async def get_overlay_status(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Check if overlay is active for a target."""
try:
active = manager.is_overlay_active(target_id)
return {"target_id": target_id, "active": active}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ===== LED PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
async def led_preview_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
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()
manager = get_processor_manager()
try:
manager.add_led_preview_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_led_preview_client(target_id, websocket)

View File

@@ -0,0 +1,540 @@
"""Output target routes: key colors endpoints, testing, and WebSocket streams.
Extracted from output_targets.py to keep files under 800 lines.
"""
import asyncio
import base64
import io
import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from PIL import Image
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.output_targets import (
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime, timezone
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.now(timezone.utc),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
async def test_kc_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None
try:
# 1. Load and validate KC target
try:
target = target_store.get_target(target_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if not isinstance(target, KeyColorsOutputTarget):
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
settings = target.settings
# 2. Resolve pattern template
if not settings.pattern_template_id:
raise HTTPException(status_code=400, detail="No pattern template configured")
try:
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
rectangles = pattern_tmpl.rectangles
if not rectangles:
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
# 3. Resolve picture source and capture a frame
if not target.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = Image.open(path).convert("RGB")
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {raw_stream.capture_template_id}",
)
display_index = raw_stream.display_index
if capture_template.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
)
locked_device_id = processor_manager.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
f"Please stop the device processing before testing.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
screen_capture = stream.capture_frame()
if screen_capture is None:
raise RuntimeError("No frame captured")
if isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
raise ValueError("Unexpected image format from engine")
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store.get_template(pp_id)
except ValueError:
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
continue
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
pil_image = Image.fromarray(img_array)
# 4. Extract colors from each rectangle
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append(KCTestRectangleResponse(
name=rect.name,
x=rect.x,
y=rect.y,
width=rect.width,
height=rect.height,
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
))
# 5. Encode frame as base64 JPEG
full_buffer = io.BytesIO()
pil_image.save(full_buffer, format='JPEG', quality=90)
full_buffer.seek(0)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
return KCTestResponse(
image=image_data_uri,
rectangles=result_rects,
interpolation_mode=settings.interpolation_mode,
pattern_template_name=pattern_tmpl.name,
)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
if stream:
try:
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
template_store_inst: TemplateStore = get_template_store()
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
processor_manager_inst: ProcessorManager = get_processor_manager()
device_store_inst: DeviceStore = get_device_store()
pp_template_store_inst = get_pp_template_store()
# Validate target
try:
target = target_store_inst.get_target(target_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(target, KeyColorsOutputTarget):
await websocket.close(code=4003, reason="Target is not a key_colors target")
return
settings = target.settings
if not settings.pattern_template_id:
await websocket.close(code=4003, reason="No pattern template configured")
return
try:
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
except ValueError:
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
return
rectangles = pattern_tmpl.rectangles
if not rectangles:
await websocket.close(code=4003, reason="Pattern template has no rectangles")
return
if not target.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
# For screen capture sources, check display lock
if isinstance(raw_stream, ScreenCapturePictureSource):
display_index = raw_stream.display_index
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store_inst.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
await websocket.close(
code=4003,
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
)
return
fps = max(1, min(30, fps))
preview_width = max(120, min(1920, preview_width))
frame_interval = 1.0 / fps
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
# Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True:
loop_start = time.monotonic()
try:
capture = await asyncio.to_thread(live_stream.get_latest_frame)
if capture is None or capture.image is None:
await asyncio.sleep(frame_interval)
continue
# Skip if same frame object (no new capture yet)
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
if pil_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError:
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
pass
pil_image = Image.fromarray(img_array)
# Extract colors
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append({
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
})
# Encode frame as JPEG
if preview_width and pil_image.width > preview_width:
ratio = preview_width / pil_image.width
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
else:
thumb = pil_image
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
await websocket.send_text(_json.dumps({
"type": "frame",
"image": f"data:image/jpeg;base64,{b64}",
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
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()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)

View File

@@ -584,7 +584,7 @@ async def test_picture_source_ws(
preview_width: int = Query(0),
):
"""WebSocket for picture source test with intermediate frame previews."""
from wled_controller.api.routes._test_helpers import (
from wled_controller.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)

View File

@@ -365,7 +365,7 @@ async def test_pp_template_ws(
preview_width: int = Query(0),
):
"""WebSocket for PP template test with intermediate frame previews."""
from wled_controller.api.routes._test_helpers import (
from wled_controller.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)

View File

@@ -1,26 +1,21 @@
"""System routes: health, version, displays, performance, backup/restore, ADB."""
"""System routes: health, version, displays, performance, tags, api-keys.
Backup/restore and settings routes are in backup.py and system_settings.py.
"""
import asyncio
import io
import json
import logging
import platform
import subprocess
import sys
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import psutil
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_auto_backup_engine,
get_audio_source_store,
get_audio_template_store,
get_automation_store,
@@ -37,29 +32,22 @@ from wled_controller.api.dependencies import (
get_value_source_store,
)
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
DisplayInfo,
DisplayListResponse,
ExternalUrlRequest,
ExternalUrlResponse,
GpuInfo,
HealthResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
PerformanceResponse,
ProcessListResponse,
RestoreResponse,
VersionResponse,
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config, is_demo_mode
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.utils import atomic_write_json, get_logger
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
# Re-export STORE_MAP and load_external_url so existing callers still work
from wled_controller.api.routes.backup import STORE_MAP # noqa: F401
from wled_controller.api.routes.system_settings import load_external_url # noqa: F401
logger = get_logger(__name__)
@@ -68,7 +56,6 @@ psutil.cpu_percent(interval=None)
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
from wled_controller.storage.base_store import EntityNotFoundError
def _get_cpu_name() -> str | None:
@@ -113,7 +100,7 @@ async def health_check():
Returns basic health information including status, version, and timestamp.
"""
logger.info("Health check requested")
logger.debug("Health check requested")
return HealthResponse(
status="healthy",
@@ -129,7 +116,7 @@ async def get_version():
Returns application version, Python version, and API version.
"""
logger.info("Version info requested")
logger.debug("Version info requested")
return VersionResponse(
version=__version__,
@@ -308,52 +295,6 @@ async def get_metrics_history(
return manager.metrics_history.get_history()
# ---------------------------------------------------------------------------
# Configuration backup / restore
# ---------------------------------------------------------------------------
# Mapping: logical store name → StorageConfig attribute name
STORE_MAP = {
"devices": "devices_file",
"capture_templates": "templates_file",
"postprocessing_templates": "postprocessing_templates_file",
"picture_sources": "picture_sources_file",
"output_targets": "output_targets_file",
"pattern_templates": "pattern_templates_file",
"color_strip_sources": "color_strip_sources_file",
"audio_sources": "audio_sources_file",
"audio_templates": "audio_templates_file",
"value_sources": "value_sources_file",
"sync_clocks": "sync_clocks_file",
"color_strip_processing_templates": "color_strip_processing_templates_file",
"automations": "automations_file",
"scene_presets": "scene_presets_file",
}
_SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes."""
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
["bash", str(_SERVER_DIR / "restart.sh")],
start_new_session=True,
)
threading.Thread(target=_restart, daemon=True).start()
@router.get("/api/v1/system/api-keys", tags=["System"])
def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file)."""
@@ -363,632 +304,3 @@ def list_api_keys(_: AuthRequired):
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
def export_store(store_key: str, _: AuthRequired):
"""Download a single entity store as a JSON file."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
export = {
"meta": {
"format": "ledgrab-partial-export",
"format_version": 1,
"store_key": store_key,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
},
"store": data,
}
content = json.dumps(export, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-{store_key}-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
async def import_store(
store_key: str,
_: AuthRequired,
file: UploadFile = File(...),
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
):
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Support both full-backup format and partial-export format
if "stores" in payload and isinstance(payload.get("meta"), dict):
# Full backup: extract the specific store
if payload["meta"].get("format") not in ("ledgrab-backup",):
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
stores = payload.get("stores", {})
if store_key not in stores:
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
incoming = stores[store_key]
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
# Partial export format
if payload["meta"].get("store_key") != store_key:
raise HTTPException(
status_code=400,
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
)
incoming = payload.get("store", {})
else:
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
if not isinstance(incoming, dict):
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
def _write():
if merge and file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict):
existing.update(incoming)
atomic_write_json(file_path, existing)
return len(existing)
atomic_write_json(file_path, incoming)
return len(incoming)
count = await asyncio.to_thread(_write)
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
"status": "imported",
"store_key": store_key,
"entries": count,
"merge": merge,
"restart_scheduled": True,
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
}
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired):
"""Download all configuration as a single JSON backup file."""
config = get_config()
stores = {}
for store_key, config_attr in STORE_MAP.items():
file_path = Path(getattr(config.storage, config_attr))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
stores[store_key] = json.load(f)
else:
stores[store_key] = {}
backup = {
"meta": {
"format": "ledgrab-backup",
"format_version": 1,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
"store_count": len(stores),
},
"stores": stores,
}
content = json.dumps(backup, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config(
_: AuthRequired,
file: UploadFile = File(...),
):
"""Upload a backup file to restore all configuration. Triggers server restart."""
# Read and parse
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
backup = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
# Validate envelope
meta = backup.get("meta")
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
fmt_version = meta.get("format_version", 0)
if fmt_version > 1:
raise HTTPException(
status_code=400,
detail=f"Backup format version {fmt_version} is not supported by this server version",
)
stores = backup.get("stores")
if not isinstance(stores, dict):
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
known_keys = set(STORE_MAP.keys())
present_keys = known_keys & set(stores.keys())
if not present_keys:
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
for key in present_keys:
if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
# Write store files atomically (in thread to avoid blocking event loop)
config = get_config()
def _write_stores():
count = 0
for store_key, config_attr in STORE_MAP.items():
if store_key in stores:
file_path = Path(getattr(config.storage, config_attr))
atomic_write_json(file_path, stores[store_key])
count += 1
logger.info(f"Restored store: {store_key} -> {file_path}")
return count
written = await asyncio.to_thread(_write_stores)
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart()
missing = known_keys - present_keys
return RestoreResponse(
status="restored",
stores_written=written,
stores_total=len(STORE_MAP),
missing_stores=sorted(missing) if missing else [],
restart_scheduled=True,
message=f"Restored {written} stores. Server restarting...",
)
# ---------------------------------------------------------------------------
# Auto-backup settings & saved backups
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def get_auto_backup_settings(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Get auto-backup settings and status."""
return engine.get_settings()
@router.put(
"/api/v1/system/auto-backup/settings",
response_model=AutoBackupStatusResponse,
tags=["System"],
)
async def update_auto_backup_settings(
_: AuthRequired,
body: AutoBackupSettings,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
async def trigger_backup(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Manually trigger a backup now."""
backup = await engine.trigger_backup()
return {"status": "ok", "backup": backup}
@router.get(
"/api/v1/system/backups",
response_model=BackupListResponse,
tags=["System"],
)
async def list_backups(
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""List all saved backup files."""
backups = engine.list_backups()
return BackupListResponse(
backups=[BackupFileInfo(**b) for b in backups],
count=len(backups),
)
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
def download_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Download a specific saved backup file."""
try:
path = engine.get_backup_path(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
content = path.read_bytes()
return StreamingResponse(
io.BytesIO(content),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
async def delete_saved_backup(
filename: str,
_: AuthRequired,
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Delete a specific saved backup file."""
try:
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "deleted", "filename": filename}
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
_MQTT_SETTINGS_FILE: Path | None = None
def _get_mqtt_settings_path() -> Path:
global _MQTT_SETTINGS_FILE
if _MQTT_SETTINGS_FILE is None:
cfg = get_config()
# Derive the data directory from any known storage file path
data_dir = Path(cfg.storage.devices_file).parent
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
return _MQTT_SETTINGS_FILE
def _load_mqtt_settings() -> dict:
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
path = _get_mqtt_settings_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
overrides = json.load(f)
defaults.update(overrides)
except Exception as e:
logger.warning(f"Failed to load MQTT settings override file: {e}")
return defaults
def _save_mqtt_settings(settings: dict) -> None:
"""Persist MQTT settings to the JSON override file."""
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_mqtt_settings_path(), settings)
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings()
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings()
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
_save_mqtt_settings(new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
@router.websocket("/api/v1/system/logs/ws")
async def logs_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket that streams server log lines in real time.
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Ensure the broadcaster knows the event loop (may be first connection)
log_broadcaster.ensure_loop()
# Subscribe *before* reading the backlog so no lines slip through
queue = log_broadcaster.subscribe()
try:
# Send backlog first
for line in log_broadcaster.get_backlog():
await websocket.send_text(line)
# Stream new lines
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
await websocket.send_text(line)
except asyncio.TimeoutError:
# Send a keepalive ping so the connection stays alive
try:
await websocket.send_text("")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
log_broadcaster.unsubscribe(queue)
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------
class AdbConnectRequest(BaseModel):
address: str
def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver."""
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@router.post("/api/v1/adb/connect", tags=["ADB"])
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
"""Connect to a WiFi ADB device by IP address.
Appends ``:5555`` if no port is specified.
"""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
if ":" not in address:
address = f"{address}:5555"
adb = _get_adb_path()
logger.info(f"Connecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
raise HTTPException(
status_code=500,
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB connect timed out")
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
"""Disconnect a WiFi ADB device."""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
# ─── Log level ─────────────────────────────────────────────────
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def get_log_level(_: AuthRequired):
"""Get the current root logger log level."""
level_int = logging.getLogger().getEffectiveLevel()
return LogLevelResponse(level=logging.getLevelName(level_int))
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
"""Change the root logger log level at runtime (no server restart required)."""
level_name = body.level.upper()
if level_name not in _VALID_LOG_LEVELS:
raise HTTPException(
status_code=400,
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
)
level_int = getattr(logging, level_name)
root = logging.getLogger()
root.setLevel(level_int)
# Also update all handlers so they actually emit at the new level
for handler in root.handlers:
handler.setLevel(level_int)
logger.info("Log level changed to %s", level_name)
return LogLevelResponse(level=level_name)

View File

@@ -0,0 +1,377 @@
"""System routes: MQTT, external URL, ADB, logs WebSocket, log level.
Extracted from system.py to keep files under 800 lines.
"""
import asyncio
import json
import logging
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from wled_controller.api.auth import AuthRequired
from wled_controller.api.schemas.system import (
ExternalUrlRequest,
ExternalUrlResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
)
from wled_controller.config import get_config
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
_MQTT_SETTINGS_FILE: Path | None = None
def _get_mqtt_settings_path() -> Path:
global _MQTT_SETTINGS_FILE
if _MQTT_SETTINGS_FILE is None:
cfg = get_config()
# Derive the data directory from any known storage file path
data_dir = Path(cfg.storage.devices_file).parent
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
return _MQTT_SETTINGS_FILE
def _load_mqtt_settings() -> dict:
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
path = _get_mqtt_settings_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
overrides = json.load(f)
defaults.update(overrides)
except Exception as e:
logger.warning(f"Failed to load MQTT settings override file: {e}")
return defaults
def _save_mqtt_settings(settings: dict) -> None:
"""Persist MQTT settings to the JSON override file."""
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_mqtt_settings_path(), settings)
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings()
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings()
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
_save_mqtt_settings(new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
@router.websocket("/api/v1/system/logs/ws")
async def logs_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket that streams server log lines in real time.
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Ensure the broadcaster knows the event loop (may be first connection)
log_broadcaster.ensure_loop()
# Subscribe *before* reading the backlog so no lines slip through
queue = log_broadcaster.subscribe()
try:
# Send backlog first
for line in log_broadcaster.get_backlog():
await websocket.send_text(line)
# Stream new lines
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
await websocket.send_text(line)
except asyncio.TimeoutError:
# Send a keepalive ping so the connection stays alive
try:
await websocket.send_text("")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
log_broadcaster.unsubscribe(queue)
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------
# Regex: IPv4 address with optional port, e.g. "192.168.1.5" or "192.168.1.5:5555"
_ADB_ADDRESS_RE = re.compile(
r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$"
)
class AdbConnectRequest(BaseModel):
address: str
def _validate_adb_address(address: str) -> None:
"""Raise 400 if *address* is not a valid IP:port for ADB."""
if not _ADB_ADDRESS_RE.match(address):
raise HTTPException(
status_code=400,
detail=(
f"Invalid ADB address '{address}'. "
"Expected format: <IP> or <IP>:<port>, e.g. 192.168.1.5 or 192.168.1.5:5555"
),
)
# Validate each octet is 0-255 and port is 1-65535
parts = address.split(":")
ip_parts = parts[0].split(".")
for octet in ip_parts:
if not (0 <= int(octet) <= 255):
raise HTTPException(
status_code=400,
detail=f"Invalid IP octet '{octet}' in address '{address}'. Each octet must be 0-255.",
)
if len(parts) == 2:
port = int(parts[1])
if not (1 <= port <= 65535):
raise HTTPException(
status_code=400,
detail=f"Invalid port '{parts[1]}' in address '{address}'. Port must be 1-65535.",
)
def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver."""
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@router.post("/api/v1/adb/connect", tags=["ADB"])
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
"""Connect to a WiFi ADB device by IP address.
Appends ``:5555`` if no port is specified.
"""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
_validate_adb_address(address)
if ":" not in address:
address = f"{address}:5555"
adb = _get_adb_path()
logger.info(f"Connecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
raise HTTPException(
status_code=500,
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB connect timed out")
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
"""Disconnect a WiFi ADB device."""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
# --- Log level -----
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def get_log_level(_: AuthRequired):
"""Get the current root logger log level."""
level_int = logging.getLogger().getEffectiveLevel()
return LogLevelResponse(level=logging.getLevelName(level_int))
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
"""Change the root logger log level at runtime (no server restart required)."""
level_name = body.level.upper()
if level_name not in _VALID_LOG_LEVELS:
raise HTTPException(
status_code=400,
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
)
level_int = getattr(logging, level_name)
root = logging.getLogger()
root.setLevel(level_int)
# Also update all handlers so they actually emit at the new level
for handler in root.handlers:
handler.setLevel(level_int)
logger.info("Log level changed to %s", level_name)
return LogLevelResponse(level=level_name)

View File

@@ -403,7 +403,7 @@ async def test_template_ws(
Config is sent as the first client message (JSON with engine_type,
engine_config, display_index, capture_duration).
"""
from wled_controller.api.routes._test_helpers import (
from wled_controller.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)

View File

@@ -6,7 +6,10 @@ automations that have a webhook condition. No API-key auth is required —
the secret token itself authenticates the caller.
"""
from fastapi import APIRouter, Depends, HTTPException
import time
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
@@ -18,6 +21,28 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Simple in-memory rate limiter: 30 requests per 60-second window per IP
# ---------------------------------------------------------------------------
_RATE_LIMIT = 30
_RATE_WINDOW = 60.0 # seconds
_rate_hits: dict[str, list[float]] = defaultdict(list)
def _check_rate_limit(client_ip: str) -> None:
"""Raise 429 if *client_ip* exceeded the webhook rate limit."""
now = time.time()
window_start = now - _RATE_WINDOW
# Prune timestamps outside the window
timestamps = _rate_hits[client_ip]
_rate_hits[client_ip] = [t for t in timestamps if t > window_start]
if len(_rate_hits[client_ip]) >= _RATE_LIMIT:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Max 30 webhook requests per minute.",
)
_rate_hits[client_ip].append(now)
class WebhookPayload(BaseModel):
action: str = Field(description="'activate' or 'deactivate'")
@@ -30,10 +55,13 @@ class WebhookPayload(BaseModel):
async def handle_webhook(
token: str,
body: WebhookPayload,
request: Request,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Receive a webhook call and set the corresponding condition state."""
_check_rate_limit(request.client.host if request.client else "unknown")
if body.action not in ("activate", "deactivate"):
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")

View File

@@ -144,6 +144,18 @@ class CalibrationTestModeResponse(BaseModel):
device_id: str = Field(description="Device ID")
class BrightnessRequest(BaseModel):
"""Request to set device brightness."""
brightness: int = Field(ge=0, le=255, description="Brightness level (0-255)")
class PowerRequest(BaseModel):
"""Request to set device power state."""
power: bool = Field(description="Whether the device should be on (true) or off (false)")
class DeviceResponse(BaseModel):
"""Device information response."""

View File

@@ -1,6 +1,10 @@
"""Target processing pipeline."""
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processor_manager import (
DeviceState,
ProcessorDependencies,
ProcessorManager,
)
from wled_controller.core.processing.target_processor import (
DeviceInfo,
ProcessingMetrics,
@@ -10,7 +14,9 @@ from wled_controller.core.processing.target_processor import (
__all__ = [
"DeviceInfo",
"DeviceState",
"ProcessingMetrics",
"ProcessorDependencies",
"ProcessorManager",
"TargetContext",
"TargetProcessor",

View File

@@ -0,0 +1,144 @@
"""Auto-restart mixin for ProcessorManager.
Handles crash detection, exponential backoff, and automatic restart
of failed target processors.
Extracted from processor_manager.py to keep files under 800 lines.
"""
import asyncio
import time
from dataclasses import dataclass
from typing import Dict, Optional
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Auto-restart constants
RESTART_MAX_ATTEMPTS = 5 # max restarts within the window
RESTART_WINDOW_SEC = 300 # 5 minutes — reset counter after stable period
RESTART_BACKOFF_BASE = 2.0 # initial backoff seconds
RESTART_BACKOFF_MAX = 30.0 # cap backoff at 30s
@dataclass
class RestartState:
"""Per-target auto-restart tracking."""
attempts: int = 0
first_crash_time: float = 0.0
last_crash_time: float = 0.0
restart_task: Optional[asyncio.Task] = None
enabled: bool = True # disabled on manual stop
class AutoRestartMixin:
"""Mixin providing auto-restart logic for crashed target processors.
Requires the host class to have:
_processors: Dict[str, TargetProcessor]
_restart_states: Dict[str, RestartState]
fire_event(event: dict) -> None
start_processing(target_id: str) -> coroutine
"""
def _on_task_done(self, target_id: str, task: asyncio.Task) -> None:
"""Task done callback — detects crashes and schedules auto-restart."""
# Ignore graceful cancellation (manual stop)
if task.cancelled():
return
exc = task.exception()
if exc is None:
return # Clean exit (shouldn't happen, but harmless)
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
return # Auto-restart disabled (manual stop was called)
now = time.monotonic()
# Reset counter if previous crash window expired
if rs.first_crash_time and (now - rs.first_crash_time) > RESTART_WINDOW_SEC:
rs.attempts = 0
rs.first_crash_time = 0.0
rs.attempts += 1
rs.last_crash_time = now
if not rs.first_crash_time:
rs.first_crash_time = now
if rs.attempts > RESTART_MAX_ATTEMPTS:
logger.error(
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
f"in {now - rs.first_crash_time:.0f}s — giving up"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_exhausted": True,
})
return
backoff = min(
RESTART_BACKOFF_BASE * (2 ** (rs.attempts - 1)),
RESTART_BACKOFF_MAX,
)
logger.warning(
f"[AUTO-RESTART] Target {target_id} crashed (attempt {rs.attempts}/"
f"{RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_in": backoff,
"auto_restart_attempt": rs.attempts,
})
# Schedule the restart (runs in the event loop)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.error(f"[AUTO-RESTART] No running event loop for {target_id}")
return
rs.restart_task = loop.create_task(self._auto_restart(target_id, backoff))
async def _auto_restart(self, target_id: str, delay: float) -> None:
"""Wait for backoff delay, then restart the target processor."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
logger.info(f"[AUTO-RESTART] Restart cancelled for {target_id}")
return
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
logger.info(f"[AUTO-RESTART] Restart aborted for {target_id} (disabled)")
return
proc = self._processors.get(target_id)
if proc is None:
logger.warning(f"[AUTO-RESTART] Target {target_id} no longer registered")
return
if proc.is_running:
logger.info(f"[AUTO-RESTART] Target {target_id} already running, skipping")
return
logger.info(f"[AUTO-RESTART] Restarting target {target_id} (attempt {rs.attempts})")
try:
await self.start_processing(target_id)
except Exception as e:
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_error": str(e),
})

View File

@@ -0,0 +1,145 @@
"""Device health monitoring mixin for ProcessorManager.
Extracted from processor_manager.py to keep files under 800 lines.
"""
import asyncio
from datetime import datetime, timezone
from wled_controller.core.devices.led_client import (
DeviceHealth,
check_device_health,
get_device_capabilities,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class DeviceHealthMixin:
"""Mixin providing health monitoring loop and health check methods.
Requires the host class to have:
_devices: Dict[str, DeviceState]
_processors: Dict[str, TargetProcessor]
_health_monitoring_active: bool
_http_client: Optional[httpx.AsyncClient]
_device_store: object
fire_event(event: dict) -> None
_get_http_client() -> httpx.AsyncClient
"""
# ===== HEALTH MONITORING =====
async def start_health_monitoring(self):
"""Start background health checks for all registered devices."""
self._health_monitoring_active = True
for device_id in self._devices:
self._start_device_health_check(device_id)
await self._metrics_history.start()
logger.info("Started health monitoring for all devices")
async def stop_health_monitoring(self):
"""Stop all background health checks."""
self._health_monitoring_active = False
for device_id in list(self._devices.keys()):
self._stop_device_health_check(device_id)
logger.info("Stopped health monitoring for all devices")
def _start_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state:
return
# Skip periodic health checks for virtual devices (always online)
if "health_check" not in get_device_capabilities(state.device_type):
state.health = DeviceHealth(
online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc)
)
return
if state.health_task and not state.health_task.done():
return
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
def _stop_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state or not state.health_task:
return
state.health_task.cancel()
state.health_task = None
def _device_is_processing(self, device_id: str) -> bool:
"""Check if any target is actively streaming to this device."""
return any(
p.device_id == device_id and p.is_running
for p in self._processors.values()
)
def _is_device_streaming(self, device_id: str) -> bool:
"""Check if any running processor targets this device."""
for proc in self._processors.values():
if getattr(proc, 'device_id', None) == device_id and proc.is_running:
return True
return False
async def _health_check_loop(self, device_id: str):
"""Background loop that periodically checks a device.
Uses adaptive intervals: 10s for actively streaming devices,
60s for idle devices, to balance responsiveness with overhead.
"""
state = self._devices.get(device_id)
if not state:
return
ACTIVE_INTERVAL = 10 # streaming devices — faster detection
IDLE_INTERVAL = 60 # idle devices — less overhead
try:
while self._health_monitoring_active:
await self._check_device_health(device_id)
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str):
"""Check device health. Also auto-syncs LED count if changed."""
state = self._devices.get(device_id)
if not state:
return
prev_online = state.health.online
client = await self._get_http_client()
state.health = await check_device_health(
state.device_type, state.device_url, client, state.health,
)
# Fire event when online status changes
if state.health.online != prev_online:
self.fire_event({
"type": "device_health_changed",
"device_id": device_id,
"online": state.health.online,
"latency_ms": state.health.latency_ms,
})
# Auto-sync LED count
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count
logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}"
)
try:
self._device_store.update_device(device_id, led_count=reported)
state.led_count = reported
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")
async def force_device_health_check(self, device_id: str) -> dict:
"""Run an immediate health check for a device and return the result."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
await self._check_device_health(device_id)
return self.get_device_health_dict(device_id)

View File

@@ -0,0 +1,189 @@
"""Device test mode and idle client management mixin for ProcessorManager.
Extracted from processor_manager.py to keep files under 800 lines.
"""
from typing import Dict, List, Optional, Tuple
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.devices.led_client import create_led_client
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class DeviceTestModeMixin:
"""Mixin providing calibration test mode and idle LED client management.
Requires the host class to have:
_devices: Dict[str, DeviceState]
_processors: Dict[str, TargetProcessor]
_idle_clients: Dict[str, object]
"""
# ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) =====
async def set_test_mode(
self,
device_id: str,
edges: Dict[str, List[int]],
calibration: Optional[CalibrationConfig] = None,
) -> None:
"""Set or clear calibration test mode for a device.
When setting test mode, pass the calibration from the CSS being tested.
When clearing (edges={}), calibration is not needed.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
ds = self._devices[device_id]
if edges:
ds.test_mode_active = True
ds.test_mode_edges = {
edge: tuple(color) for edge, color in edges.items()
}
if calibration is not None:
ds.test_calibration = calibration
await self._send_test_pixels(device_id)
else:
ds.test_mode_active = False
ds.test_mode_edges = {}
ds.test_calibration = None
await self._send_clear_pixels(device_id)
async def _get_idle_client(self, device_id: str):
"""Get or create a cached idle LED client for a device.
Reuses an existing connected client to avoid repeated serial
reconnection (which triggers Arduino bootloader reset on Adalight).
"""
# Prefer a running processor's client (already connected)
active = self._find_active_led_client(device_id)
if active:
return active
# Reuse cached idle client if still connected
cached = self._idle_clients.get(device_id)
if cached and cached.is_connected:
return cached
# Create and cache a new client
ds = self._devices[device_id]
client = create_led_client(
ds.device_type, ds.device_url,
use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate,
)
await client.connect()
self._idle_clients[device_id] = client
return client
async def _close_idle_client(self, device_id: str) -> None:
"""Close and remove the cached idle client for a device."""
client = self._idle_clients.pop(device_id, None)
if client:
try:
await client.close()
except Exception as e:
logger.warning(f"Error closing idle client for {device_id}: {e}")
async def _send_test_pixels(self, device_id: str) -> None:
"""Build and send test pixel array for active test edges."""
ds = self._devices[device_id]
# Require calibration to know which LEDs map to which edges
if ds.test_calibration is None:
logger.debug(f"No calibration for test mode on {device_id}, skipping LED test")
return
pixels = [(0, 0, 0)] * ds.led_count
for edge_name, color in ds.test_mode_edges.items():
for seg in ds.test_calibration.segments:
if seg.edge == edge_name:
for i in range(seg.led_start, seg.led_start + seg.led_count):
if i < ds.led_count:
pixels[i] = color
break
# Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds)
total_leds = ds.test_calibration.get_total_leds()
offset = ds.test_calibration.offset % total_leds if total_leds > 0 else 0
if offset > 0:
pixels = pixels[-offset:] + pixels[:-offset]
await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear LED output."""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
await self._send_pixels_to_device(device_id, pixels)
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices).
"""
try:
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send pixels to {device_id}: {e}")
def _find_active_led_client(self, device_id: str):
"""Find an active LED client for a device (from a running processor)."""
for proc in self._processors.values():
if proc.device_id == device_id and proc.is_running and proc.led_client:
return proc.led_client
return None
# ===== DISPLAY LOCK INFO =====
def is_display_locked(self, display_index: int) -> bool:
"""Check if a display is currently being captured by any target."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return True
return False
def get_display_lock_info(self, display_index: int) -> Optional[str]:
"""Get the device ID that is currently capturing from a display."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return proc.device_id
return None
async def clear_device(self, device_id: str) -> None:
"""Clear LED output on a device (send black / power off)."""
ds = self._devices.get(device_id)
if not ds:
raise ValueError(f"Device {device_id} not found")
try:
await self._send_clear_pixels(device_id)
except Exception as e:
logger.error(f"Failed to clear device {device_id}: {e}")
async def _restore_device_idle_state(self, device_id: str) -> None:
"""Restore a device to its idle state when all targets stop.
- For WLED: do nothing — stop() already restored the snapshot.
- For serial: do nothing — AdalightClient.close() already sent black frame.
"""
ds = self._devices.get(device_id)
if not ds or not ds.auto_shutdown:
return
if self.is_device_processing(device_id):
return
if ds.device_type == "wled":
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
else:
logger.info(f"Auto-restore: {ds.device_type} device {device_id} dark (closed by processor)")
async def send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to a device (public wrapper)."""
await self._send_clear_pixels(device_id)

View File

@@ -8,13 +8,7 @@ from typing import Dict, List, Optional, Tuple
import httpx
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.devices.led_client import (
DeviceHealth,
check_device_health,
create_led_client,
get_device_capabilities,
get_provider,
)
from wled_controller.core.devices.led_client import DeviceHealth
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
@@ -28,27 +22,39 @@ from wled_controller.core.processing.target_processor import (
)
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
from wled_controller.core.processing.auto_restart import (
AutoRestartMixin,
RestartState as _RestartState,
RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC,
)
from wled_controller.core.processing.device_health import DeviceHealthMixin
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
from wled_controller.utils import get_logger
logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# Auto-restart constants
_RESTART_MAX_ATTEMPTS = 5 # max restarts within the window
_RESTART_WINDOW_SEC = 300 # 5 minutes — reset counter after stable period
_RESTART_BACKOFF_BASE = 2.0 # initial backoff seconds
_RESTART_BACKOFF_MAX = 30.0 # cap backoff at 30s
@dataclass
class _RestartState:
"""Per-target auto-restart tracking."""
attempts: int = 0
first_crash_time: float = 0.0
last_crash_time: float = 0.0
restart_task: Optional[asyncio.Task] = None
enabled: bool = True # disabled on manual stop
class ProcessorDependencies:
"""Bundles all store and manager references needed by ProcessorManager.
Keeps the constructor signature stable when new stores are added.
"""
picture_source_store: object = None
capture_template_store: object = None
pp_template_store: object = None
pattern_template_store: object = None
device_store: object = None
color_strip_store: object = None
audio_source_store: object = None
audio_template_store: object = None
value_source_store: object = None
sync_clock_manager: object = None
cspt_store: object = None
@dataclass
@@ -79,51 +85,58 @@ class DeviceState:
zone_mode: str = "combined"
class ProcessorManager:
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
"""Manages devices and delegates target processing to TargetProcessor instances.
Devices are registered for health monitoring.
Targets are registered for processing via polymorphic TargetProcessor subclasses.
Health monitoring is provided by DeviceHealthMixin.
Test mode and idle client management is provided by DeviceTestModeMixin.
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None, cspt_store=None):
"""Initialize processor manager."""
def __init__(self, deps: ProcessorDependencies):
"""Initialize processor manager.
Args:
deps: Bundled store and manager references.
"""
self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {}
self._idle_clients: Dict[str, object] = {} # device_id -> cached LEDClient
self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None
self._picture_source_store = picture_source_store
self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store
self._pattern_template_store = pattern_template_store
self._device_store = device_store
self._color_strip_store = color_strip_store
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._value_source_store = value_source_store
self._cspt_store = cspt_store
self._picture_source_store = deps.picture_source_store
self._capture_template_store = deps.capture_template_store
self._pp_template_store = deps.pp_template_store
self._pattern_template_store = deps.pattern_template_store
self._device_store = deps.device_store
self._color_strip_store = deps.color_strip_store
self._audio_source_store = deps.audio_source_store
self._audio_template_store = deps.audio_template_store
self._value_source_store = deps.value_source_store
self._cspt_store = deps.cspt_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store
)
self._audio_capture_manager = AudioCaptureManager()
self._sync_clock_manager = sync_clock_manager
self._sync_clock_manager = deps.sync_clock_manager
self._color_strip_stream_manager = ColorStripStreamManager(
color_strip_store=color_strip_store,
color_strip_store=deps.color_strip_store,
live_stream_manager=self._live_stream_manager,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
cspt_store=cspt_store,
audio_source_store=deps.audio_source_store,
audio_template_store=deps.audio_template_store,
sync_clock_manager=deps.sync_clock_manager,
cspt_store=deps.cspt_store,
)
self._value_stream_manager = ValueStreamManager(
value_source_store=value_source_store,
value_source_store=deps.value_source_store,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
audio_source_store=deps.audio_source_store,
live_stream_manager=self._live_stream_manager,
audio_template_store=audio_template_store,
) if value_source_store else None
audio_template_store=deps.audio_template_store,
) if deps.value_source_store else None
# Wire value stream manager into CSS stream manager for composite layer brightness
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
self._overlay_manager = OverlayManager()
@@ -167,70 +180,37 @@ class ProcessorManager:
get_device_info=self._get_device_info,
)
# Default values for device-specific fields read from persistent storage
_DEVICE_FIELD_DEFAULTS = {
"send_latency_ms": 0, "rgbw": False, "dmx_protocol": "artnet",
"dmx_start_universe": 0, "dmx_start_channel": 1, "espnow_peer_mac": "",
"espnow_channel": 1, "hue_username": "", "hue_client_key": "",
"hue_entertainment_group_id": "", "spi_speed_hz": 800000,
"spi_led_type": "WS2812B", "chroma_device_type": "chromalink",
"gamesense_device_type": "keyboard",
}
def _get_device_info(self, device_id: str) -> Optional[DeviceInfo]:
"""Create a DeviceInfo snapshot from the current device state."""
ds = self._devices.get(device_id)
if ds is None:
return None
# Read device-specific fields from persistent storage
send_latency_ms = 0
rgbw = False
dmx_protocol = "artnet"
dmx_start_universe = 0
dmx_start_channel = 1
espnow_peer_mac = ""
espnow_channel = 1
hue_username = ""
hue_client_key = ""
hue_entertainment_group_id = ""
spi_speed_hz = 800000
spi_led_type = "WS2812B"
chroma_device_type = "chromalink"
gamesense_device_type = "keyboard"
extras = dict(self._DEVICE_FIELD_DEFAULTS)
if self._device_store:
try:
dev = self._device_store.get_device(ds.device_id)
send_latency_ms = getattr(dev, "send_latency_ms", 0)
rgbw = getattr(dev, "rgbw", False)
dmx_protocol = getattr(dev, "dmx_protocol", "artnet")
dmx_start_universe = getattr(dev, "dmx_start_universe", 0)
dmx_start_channel = getattr(dev, "dmx_start_channel", 1)
espnow_peer_mac = getattr(dev, "espnow_peer_mac", "")
espnow_channel = getattr(dev, "espnow_channel", 1)
hue_username = getattr(dev, "hue_username", "")
hue_client_key = getattr(dev, "hue_client_key", "")
hue_entertainment_group_id = getattr(dev, "hue_entertainment_group_id", "")
spi_speed_hz = getattr(dev, "spi_speed_hz", 800000)
spi_led_type = getattr(dev, "spi_led_type", "WS2812B")
chroma_device_type = getattr(dev, "chroma_device_type", "chromalink")
gamesense_device_type = getattr(dev, "gamesense_device_type", "keyboard")
for key, default in self._DEVICE_FIELD_DEFAULTS.items():
extras[key] = getattr(dev, key, default)
except ValueError:
pass
return DeviceInfo(
device_id=ds.device_id,
device_url=ds.device_url,
led_count=ds.led_count,
device_type=ds.device_type,
baud_rate=ds.baud_rate,
software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
zone_mode=ds.zone_mode,
auto_shutdown=ds.auto_shutdown,
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,
device_id=ds.device_id, device_url=ds.device_url,
led_count=ds.led_count, device_type=ds.device_type,
baud_rate=ds.baud_rate, software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active, zone_mode=ds.zone_mode,
auto_shutdown=ds.auto_shutdown, **extras,
)
# ===== EVENT SYSTEM (state change notifications) =====
@@ -260,7 +240,7 @@ class ProcessorManager:
self._http_client = httpx.AsyncClient(timeout=5)
return self._http_client
# ===== DEVICE MANAGEMENT (health monitoring) =====
# ===== DEVICE MANAGEMENT =====
def add_device(
self,
@@ -475,7 +455,7 @@ class ProcessorManager:
async def update_target_device(self, target_id: str, device_id: str):
"""Update the device for a target.
If the target is currently running, performs a stop swap start
If the target is currently running, performs a stop -> swap -> start
cycle so the new device connection is established properly.
"""
proc = self._get_processor(target_id)
@@ -495,7 +475,7 @@ class ProcessorManager:
if was_running:
await self.start_processing(target_id)
logger.info(
"Hot-switch complete for target %s device %s",
"Hot-switch complete for target %s -> device %s",
target_id, device_id,
)
@@ -742,272 +722,6 @@ class ProcessorManager:
if proc:
proc.remove_led_preview_client(ws)
# ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) =====
async def set_test_mode(
self,
device_id: str,
edges: Dict[str, List[int]],
calibration: Optional[CalibrationConfig] = None,
) -> None:
"""Set or clear calibration test mode for a device.
When setting test mode, pass the calibration from the CSS being tested.
When clearing (edges={}), calibration is not needed.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
ds = self._devices[device_id]
if edges:
ds.test_mode_active = True
ds.test_mode_edges = {
edge: tuple(color) for edge, color in edges.items()
}
if calibration is not None:
ds.test_calibration = calibration
await self._send_test_pixels(device_id)
else:
ds.test_mode_active = False
ds.test_mode_edges = {}
ds.test_calibration = None
await self._send_clear_pixels(device_id)
async def _get_idle_client(self, device_id: str):
"""Get or create a cached idle LED client for a device.
Reuses an existing connected client to avoid repeated serial
reconnection (which triggers Arduino bootloader reset on Adalight).
"""
# Prefer a running processor's client (already connected)
active = self._find_active_led_client(device_id)
if active:
return active
# Reuse cached idle client if still connected
cached = self._idle_clients.get(device_id)
if cached and cached.is_connected:
return cached
# Create and cache a new client
ds = self._devices[device_id]
client = create_led_client(
ds.device_type, ds.device_url,
use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate,
)
await client.connect()
self._idle_clients[device_id] = client
return client
async def _close_idle_client(self, device_id: str) -> None:
"""Close and remove the cached idle client for a device."""
client = self._idle_clients.pop(device_id, None)
if client:
try:
await client.close()
except Exception as e:
logger.warning(f"Error closing idle client for {device_id}: {e}")
async def _send_test_pixels(self, device_id: str) -> None:
"""Build and send test pixel array for active test edges."""
ds = self._devices[device_id]
# Require calibration to know which LEDs map to which edges
if ds.test_calibration is None:
logger.debug(f"No calibration for test mode on {device_id}, skipping LED test")
return
pixels = [(0, 0, 0)] * ds.led_count
for edge_name, color in ds.test_mode_edges.items():
for seg in ds.test_calibration.segments:
if seg.edge == edge_name:
for i in range(seg.led_start, seg.led_start + seg.led_count):
if i < ds.led_count:
pixels[i] = color
break
# Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds)
total_leds = ds.test_calibration.get_total_leds()
offset = ds.test_calibration.offset % total_leds if total_leds > 0 else 0
if offset > 0:
pixels = pixels[-offset:] + pixels[:-offset]
await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear LED output."""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
await self._send_pixels_to_device(device_id, pixels)
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices).
"""
try:
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send pixels to {device_id}: {e}")
def _find_active_led_client(self, device_id: str):
"""Find an active LED client for a device (from a running processor)."""
for proc in self._processors.values():
if proc.device_id == device_id and proc.is_running and proc.led_client:
return proc.led_client
return None
# ===== DISPLAY LOCK INFO =====
def is_display_locked(self, display_index: int) -> bool:
"""Check if a display is currently being captured by any target."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return True
return False
def get_display_lock_info(self, display_index: int) -> Optional[str]:
"""Get the device ID that is currently capturing from a display."""
for proc in self._processors.values():
if proc.is_running and proc.get_display_index() == display_index:
return proc.device_id
return None
async def clear_device(self, device_id: str) -> None:
"""Clear LED output on a device (send black / power off)."""
ds = self._devices.get(device_id)
if not ds:
raise ValueError(f"Device {device_id} not found")
try:
await self._send_clear_pixels(device_id)
except Exception as e:
logger.error(f"Failed to clear device {device_id}: {e}")
async def _restore_device_idle_state(self, device_id: str) -> None:
"""Restore a device to its idle state when all targets stop.
- For WLED: do nothing — stop() already restored the snapshot.
- For serial: do nothing — AdalightClient.close() already sent black frame.
"""
ds = self._devices.get(device_id)
if not ds or not ds.auto_shutdown:
return
if self.is_device_processing(device_id):
return
if ds.device_type == "wled":
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
else:
logger.info(f"Auto-restore: {ds.device_type} device {device_id} dark (closed by processor)")
# ===== AUTO-RESTART =====
def _on_task_done(self, target_id: str, task: asyncio.Task) -> None:
"""Task done callback — detects crashes and schedules auto-restart."""
# Ignore graceful cancellation (manual stop)
if task.cancelled():
return
exc = task.exception()
if exc is None:
return # Clean exit (shouldn't happen, but harmless)
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
return # Auto-restart disabled (manual stop was called)
now = time.monotonic()
# Reset counter if previous crash window expired
if rs.first_crash_time and (now - rs.first_crash_time) > _RESTART_WINDOW_SEC:
rs.attempts = 0
rs.first_crash_time = 0.0
rs.attempts += 1
rs.last_crash_time = now
if not rs.first_crash_time:
rs.first_crash_time = now
if rs.attempts > _RESTART_MAX_ATTEMPTS:
logger.error(
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
f"in {now - rs.first_crash_time:.0f}s — giving up"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_exhausted": True,
})
return
backoff = min(
_RESTART_BACKOFF_BASE * (2 ** (rs.attempts - 1)),
_RESTART_BACKOFF_MAX,
)
logger.warning(
f"[AUTO-RESTART] Target {target_id} crashed (attempt {rs.attempts}/"
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
)
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_in": backoff,
"auto_restart_attempt": rs.attempts,
})
# Schedule the restart (runs in the event loop)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.error(f"[AUTO-RESTART] No running event loop for {target_id}")
return
rs.restart_task = loop.create_task(self._auto_restart(target_id, backoff))
async def _auto_restart(self, target_id: str, delay: float) -> None:
"""Wait for backoff delay, then restart the target processor."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
logger.info(f"[AUTO-RESTART] Restart cancelled for {target_id}")
return
rs = self._restart_states.get(target_id)
if not rs or not rs.enabled:
logger.info(f"[AUTO-RESTART] Restart aborted for {target_id} (disabled)")
return
proc = self._processors.get(target_id)
if proc is None:
logger.warning(f"[AUTO-RESTART] Target {target_id} no longer registered")
return
if proc.is_running:
logger.info(f"[AUTO-RESTART] Target {target_id} already running, skipping")
return
logger.info(f"[AUTO-RESTART] Restarting target {target_id} (attempt {rs.attempts})")
try:
await self.start_processing(target_id)
except Exception as e:
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
"crashed": True,
"auto_restart_error": str(e),
})
# ===== LIFECYCLE =====
async def stop_all(self):
@@ -1055,120 +769,6 @@ class ProcessorManager:
logger.info("Stopped all processors")
# ===== HEALTH MONITORING =====
async def start_health_monitoring(self):
"""Start background health checks for all registered devices."""
self._health_monitoring_active = True
for device_id in self._devices:
self._start_device_health_check(device_id)
await self._metrics_history.start()
logger.info("Started health monitoring for all devices")
async def stop_health_monitoring(self):
"""Stop all background health checks."""
self._health_monitoring_active = False
for device_id in list(self._devices.keys()):
self._stop_device_health_check(device_id)
logger.info("Stopped health monitoring for all devices")
def _start_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state:
return
# Skip periodic health checks for virtual devices (always online)
if "health_check" not in get_device_capabilities(state.device_type):
from datetime import datetime, timezone
state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
return
if state.health_task and not state.health_task.done():
return
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
def _stop_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state or not state.health_task:
return
state.health_task.cancel()
state.health_task = None
def _device_is_processing(self, device_id: str) -> bool:
"""Check if any target is actively streaming to this device."""
return any(
p.device_id == device_id and p.is_running
for p in self._processors.values()
)
def _is_device_streaming(self, device_id: str) -> bool:
"""Check if any running processor targets this device."""
for proc in self._processors.values():
if getattr(proc, 'device_id', None) == device_id and proc.is_running:
return True
return False
async def _health_check_loop(self, device_id: str):
"""Background loop that periodically checks a device.
Uses adaptive intervals: 10s for actively streaming devices,
60s for idle devices, to balance responsiveness with overhead.
"""
state = self._devices.get(device_id)
if not state:
return
ACTIVE_INTERVAL = 10 # streaming devices — faster detection
IDLE_INTERVAL = 60 # idle devices — less overhead
try:
while self._health_monitoring_active:
await self._check_device_health(device_id)
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str):
"""Check device health. Also auto-syncs LED count if changed."""
state = self._devices.get(device_id)
if not state:
return
prev_online = state.health.online
client = await self._get_http_client()
state.health = await check_device_health(
state.device_type, state.device_url, client, state.health,
)
# Fire event when online status changes
if state.health.online != prev_online:
self.fire_event({
"type": "device_health_changed",
"device_id": device_id,
"online": state.health.online,
"latency_ms": state.health.latency_ms,
})
# Auto-sync LED count
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count
logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}"
)
try:
self._device_store.update_device(device_id, led_count=reported)
state.led_count = reported
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")
async def force_device_health_check(self, device_id: str) -> dict:
"""Run an immediate health check for a device and return the result."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
await self._check_device_health(device_id)
return self.get_device_health_dict(device_id)
# ===== HELPERS =====
def has_device(self, device_id: str) -> bool:
@@ -1179,10 +779,6 @@ class ProcessorManager:
"""Get device state, returning None if not registered."""
return self._devices.get(device_id)
async def send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to a device (public wrapper)."""
await self._send_clear_pixels(device_id)
def get_processor(self, target_id: str) -> Optional[TargetProcessor]:
"""Look up a processor by target_id, returning None if not found."""
return self._processors.get(target_id)

View File

@@ -16,7 +16,7 @@ from wled_controller import __version__
from wled_controller.api import router
from wled_controller.api.dependencies import init_dependencies
from wled_controller.config import get_config
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processor_manager import ProcessorDependencies, ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
@@ -72,17 +72,19 @@ cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_proces
sync_clock_manager = SyncClockManager(sync_clock_store)
processor_manager = ProcessorManager(
picture_source_store=picture_source_store,
capture_template_store=template_store,
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
color_strip_store=color_strip_store,
audio_source_store=audio_source_store,
value_source_store=value_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
cspt_store=cspt_store,
ProcessorDependencies(
picture_source_store=picture_source_store,
capture_template_store=template_store,
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
color_strip_store=color_strip_store,
audio_source_store=audio_source_store,
value_source_store=value_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
cspt_store=cspt_store,
)
)
@@ -128,10 +130,12 @@ async def lifespan(app: FastAPI):
device_store=device_store,
)
# Create auto-backup engine
# Create auto-backup engine — derive paths from storage config so that
# demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.devices_file).parent
auto_backup_engine = AutoBackupEngine(
settings_path=Path("data/auto_backup_settings.json"),
backup_dir=Path("data/backups"),
settings_path=_data_dir / "auto_backup_settings.json",
backup_dir=_data_dir / "backups",
store_map=STORE_MAP,
storage_config=config.storage,
)
@@ -314,14 +318,17 @@ templates = Jinja2Templates(directory=str(templates_path))
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""Global exception handler for unhandled errors."""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
import uuid
ref_id = uuid.uuid4().hex[:8]
logger.error("Unhandled exception [ref=%s]: %s", ref_id, exc, exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": "InternalServerError",
"message": "An unexpected error occurred",
"detail": str(exc) if config.server.log_level == "DEBUG" else None,
"message": "Internal server error",
"ref": ref_id,
},
)

View File

@@ -532,7 +532,6 @@ input:-webkit-autofill:focus {
}
.tag-input-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
@@ -545,6 +544,16 @@ input:-webkit-autofill:focus {
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: opacity var(--duration-fast) ease-out,
transform var(--duration-fast) var(--ease-out);
}
.tag-input-dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tag-dropdown-item {
@@ -668,11 +677,14 @@ textarea:focus-visible {
z-index: var(--z-lightbox);
overflow: hidden;
opacity: 0;
transition: opacity 0.15s ease;
transform: translateY(-6px) scale(0.97);
transition: opacity var(--duration-fast) ease-out,
transform var(--duration-normal) var(--ease-out);
pointer-events: none;
}
.icon-select-popup.open {
opacity: 1;
transform: translateY(0) scale(1);
overflow-y: auto;
pointer-events: auto;
}
@@ -816,17 +828,26 @@ textarea:focus-visible {
/* ── Entity Palette (command-palette style selector) ─────── */
.entity-palette-overlay {
display: none;
display: flex;
position: fixed;
inset: 0;
z-index: var(--z-lightbox);
background: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0);
justify-content: center;
align-items: flex-start;
padding-top: min(20vh, 120px);
pointer-events: none;
opacity: 0;
backdrop-filter: blur(0px);
transition: background var(--duration-fast) ease-out,
opacity var(--duration-fast) ease-out,
backdrop-filter var(--duration-fast) ease-out;
}
.entity-palette-overlay.open {
display: flex;
opacity: 1;
pointer-events: auto;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.entity-palette {
width: min(500px, 90vw);
@@ -838,6 +859,14 @@ textarea:focus-visible {
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0;
transform: translateY(-12px) scale(0.98);
transition: opacity var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
}
.entity-palette-overlay.open .entity-palette {
opacity: 1;
transform: translateY(0) scale(1);
}
.entity-palette-search-row {
display: flex;

View File

@@ -5,6 +5,7 @@
// Layer 0: state
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
import { Modal } from './core/modal.ts';
import { queryEl } from './core/dom-utils.ts';
// Layer 1: api, i18n
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
@@ -93,8 +94,8 @@ import {
} from './features/automations.ts';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset,
addSceneTarget, removeSceneTarget,
activateScenePreset, cloneScenePreset, deleteScenePreset,
addSceneTarget,
} from './features/scene-presets.ts';
// Layer 5: device-discovery, targets
@@ -380,17 +381,15 @@ Object.assign(window, {
deleteAutomation,
copyWebhookUrl,
// scene presets
// scene presets (modal buttons stay on window; card actions migrated to event delegation)
openScenePresetCapture,
editScenePreset,
saveScenePreset,
closeScenePresetEditor,
activateScenePreset,
recaptureScenePreset,
cloneScenePreset,
deleteScenePreset,
addSceneTarget,
removeSceneTarget,
// device-discovery
onDeviceTypeChanged,
@@ -577,13 +576,13 @@ document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Close in order: log overlay > overlay lightboxes > modals via stack
const logOverlay = document.getElementById('log-overlay');
const logOverlay = queryEl('log-overlay');
if (logOverlay && logOverlay.style.display !== 'none') {
closeLogOverlay();
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker(null as any);
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox(null as any);
} else if (queryEl('display-picker-lightbox')?.classList.contains('active')) {
closeDisplayPicker();
} else if (queryEl('image-lightbox')?.classList.contains('active')) {
closeLightbox();
} else {
Modal.closeTopmost();
}
@@ -656,7 +655,8 @@ document.addEventListener('DOMContentLoaded', async () => {
initCommandPalette();
// Setup form handler
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
const addDeviceForm = queryEl('add-device-form');
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
// Always monitor server connection (even before login)
loadServerInfo();

View File

@@ -5,6 +5,7 @@
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { t } from './i18n.ts';
import { showToast } from './ui.ts';
import { getEl, queryEl } from './dom-utils.ts';
export const API_BASE = '/api/v1';
@@ -77,7 +78,7 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
throw err;
}
}
return undefined as unknown as Response;
throw new Error('fetchWithAuth: unreachable code — retry loop exhausted');
}
export function escapeHtml(text: string) {
@@ -188,8 +189,10 @@ export async function loadServerInfo() {
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
const data = await response.json();
document.getElementById('version-number')!.textContent = `v${data.version}`;
document.getElementById('server-status')!.textContent = '●';
const versionEl = queryEl('version-number');
if (versionEl) versionEl.textContent = `v${data.version}`;
const statusEl = queryEl('server-status');
if (statusEl) statusEl.textContent = '●';
const wasOffline = _serverOnline === false;
_setConnectionState(true);
if (wasOffline) {
@@ -263,11 +266,13 @@ export function configureApiKey() {
if (key === '') {
localStorage.removeItem('wled_api_key');
setApiKey(null);
document.getElementById('api-key-btn')!.style.display = 'none';
const keyBtnHide = queryEl('api-key-btn');
if (keyBtnHide) keyBtnHide.style.display = 'none';
} else {
localStorage.setItem('wled_api_key', key);
setApiKey(key);
document.getElementById('api-key-btn')!.style.display = 'inline-block';
const keyBtnShow = queryEl('api-key-btn');
if (keyBtnShow) keyBtnShow.style.display = 'inline-block';
}
loadServerInfo();

View File

@@ -106,8 +106,8 @@ void main() {
}
`;
let _canvas, _gl, _prog;
let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticlesBase;
let _canvas: HTMLCanvasElement = undefined as any, _gl: WebGLRenderingContext | null = null, _prog: WebGLProgram | null = null;
let _uTime: WebGLUniformLocation | null, _uRes: WebGLUniformLocation | null, _uAccent: WebGLUniformLocation | null, _uBg: WebGLUniformLocation | null, _uLight: WebGLUniformLocation | null, _uParticlesBase: WebGLUniformLocation | null;
let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for uniform3fv
let _raf: number | null = null;
let _startTime = 0;
@@ -116,7 +116,8 @@ let _bgColor = [26 / 255, 26 / 255, 26 / 255];
let _isLight = 0.0;
// Particle state (CPU-side, positions in 0..1 UV space)
const _particles = [];
interface Particle { x: number; y: number; vx: number; vy: number; r: number; }
const _particles: Particle[] = [];
function _initParticles(): void {
_particles.length = 0;
@@ -144,6 +145,7 @@ function _updateParticles(): void {
function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
const s = gl.createShader(type);
if (!s) return null;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
@@ -162,9 +164,9 @@ function _initGL(): boolean {
const fs = _compile(gl, gl.FRAGMENT_SHADER, FRAG_SRC);
if (!vs || !fs) return false;
_prog = gl.createProgram();
gl.attachShader(_prog, vs);
gl.attachShader(_prog, fs);
_prog = gl.createProgram()!;
gl.attachShader(_prog!, vs);
gl.attachShader(_prog!, fs);
gl.linkProgram(_prog);
if (!gl.getProgramParameter(_prog, gl.LINK_STATUS)) {
console.error('Program link:', gl.getProgramInfoLog(_prog));
@@ -213,14 +215,16 @@ function _draw(time: number): void {
gl.uniform3f(_uBg, _bgColor[0], _bgColor[1], _bgColor[2]);
gl.uniform1f(_uLight, _isLight);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = _particles[i];
const off = i * 3;
_particleBuf[off] = p.x;
_particleBuf[off + 1] = p.y;
_particleBuf[off + 2] = p.r;
if (_particleBuf) {
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = _particles[i];
const off = i * 3;
_particleBuf[off] = p.x;
_particleBuf[off + 1] = p.y;
_particleBuf[off + 2] = p.r;
}
gl.uniform3fv(_uParticlesBase, _particleBuf);
}
gl.uniform3fv(_uParticlesBase, _particleBuf);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
@@ -259,8 +263,9 @@ export function updateBgAnimTheme(isDark: boolean): void {
}
export function initBgAnim(): void {
_canvas = document.getElementById('bg-anim-canvas');
if (!_canvas) return;
const canvasEl = document.getElementById('bg-anim-canvas') as HTMLCanvasElement | null;
if (!canvasEl) return;
_canvas = canvasEl;
const observer = new MutationObserver(() => {
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';

View File

@@ -72,7 +72,7 @@ function _render() {
`;
// Select All checkbox
el.querySelector('.bulk-select-all-cb').addEventListener('change', (e) => {
el.querySelector('.bulk-select-all-cb')!.addEventListener('change', (e) => {
if ((e.target as HTMLInputElement).checked) section.selectAll();
else section.deselectAll();
});
@@ -83,7 +83,7 @@ function _render() {
});
// Close button
el.querySelector('.bulk-close').addEventListener('click', () => {
el.querySelector('.bulk-close')!.addEventListener('click', () => {
section.exitSelectionMode();
});
@@ -94,7 +94,7 @@ async function _executeAction(actionKey) {
const section = _activeSection;
if (!section) return;
const action = section.bulkActions.find(a => a.key === actionKey);
const action = section.bulkActions!.find(a => a.key === actionKey);
if (!action) return;
const keys = [...section._selected];

View File

@@ -25,7 +25,7 @@ const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080';
function _getAll(): Record<string, string> {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; }
catch { return {}; }
}
@@ -66,7 +66,7 @@ export function cardColorButton(entityId: string, cardAttr: string): string {
if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : '';
});
return createColorPicker({ id: pickerId, currentColor: color, onPick: null, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
}
/**

View File

@@ -22,8 +22,8 @@ function _onMove(e) {
_cachedRect = card.getBoundingClientRect();
}
const x = e.clientX - _cachedRect.left;
const y = e.clientY - _cachedRect.top;
const x = e.clientX - _cachedRect!.left;
const y = e.clientY - _cachedRect!.top;
card.style.setProperty('--glare-x', `${x}px`);
card.style.setProperty('--glare-y', `${y}px`);
} else if (_active) {

View File

@@ -343,12 +343,12 @@ export class CardSection {
if (this.keyAttr) {
const existing = [...content.querySelectorAll(`[${this.keyAttr}]`)];
for (const card of existing) {
const key = card.getAttribute(this.keyAttr);
const key = card.getAttribute(this.keyAttr) ?? '';
if (!newMap.has(key)) {
card.remove();
removed.add(key!);
removed.add(key);
} else {
const newHtml = newMap.get(key);
const newHtml = newMap.get(key)!;
if ((card as any)._csHtml !== newHtml) {
const tmp = document.createElement('div');
tmp.innerHTML = newHtml;
@@ -620,7 +620,7 @@ export class CardSection {
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
cards.forEach(card => {
const key = card.getAttribute(this.keyAttr);
const key = card.getAttribute(this.keyAttr) ?? '';
if (htmlMap.has(key)) (card as any)._csHtml = htmlMap.get(key);
});
}

View File

@@ -19,6 +19,8 @@ let _items: any[] = [];
let _filtered: any[] = [];
let _selectedIdx = 0;
let _loading = false;
let _inputDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const _INPUT_DEBOUNCE_MS = 150;
// ─── Entity definitions: endpoint → palette items ───
@@ -35,7 +37,7 @@ function _mapEntities(data: any, mapFn: (item: any) => any) {
function _buildItems(results: any[], states: any = {}) {
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
const items = [];
const items: any[] = [];
_mapEntities(devices, d => items.push({
name: d.name, detail: d.device_type, group: 'devices', icon: ICON_DEVICE,
@@ -199,7 +201,7 @@ async function _fetchAllEntities() {
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
.then((r: any) => r.ok ? r.json() : {})
.then((data: any) => data[key as string] || [])
.catch(() => [])),
.catch((): any[] => [])),
]);
return _buildItems(results, statesData);
}
@@ -316,6 +318,10 @@ export async function openCommandPalette() {
export function closeCommandPalette() {
if (!_isOpen) return;
_isOpen = false;
if (_inputDebounceTimer !== null) {
clearTimeout(_inputDebounceTimer);
_inputDebounceTimer = null;
}
const overlay = document.getElementById('command-palette')!;
overlay.style.display = 'none';
document.documentElement.classList.remove('modal-open');
@@ -326,10 +332,14 @@ export function closeCommandPalette() {
// ─── Event handlers ───
function _onInput() {
const input = document.getElementById('cp-input') as HTMLInputElement;
_filtered = _filterItems(input.value.trim());
_selectedIdx = 0;
_render();
if (_inputDebounceTimer !== null) clearTimeout(_inputDebounceTimer);
_inputDebounceTimer = setTimeout(() => {
_inputDebounceTimer = null;
const input = document.getElementById('cp-input') as HTMLInputElement;
_filtered = _filterItems(input.value.trim());
_selectedIdx = 0;
_render();
}, _INPUT_DEBOUNCE_MS);
}
function _onKeydown(e: KeyboardEvent) {
@@ -355,7 +365,7 @@ function _onKeydown(e: KeyboardEvent) {
function _onClick(e: Event) {
const row = (e.target as HTMLElement).closest('.cp-result') as HTMLElement | null;
if (row) {
_selectedIdx = parseInt(row.dataset.cpIdx, 10);
_selectedIdx = parseInt(row.dataset.cpIdx ?? '0', 10);
_selectCurrent();
return;
}

View File

@@ -0,0 +1,23 @@
/**
* DOM utility functions — safe element access, type-checked selectors.
*/
/**
* Get an element by ID with a meaningful error if not found.
* Use this instead of `document.getElementById('x')!` to get
* a clear error message pointing to the missing element.
*/
export function getEl(id: string): HTMLElement {
const el = document.getElementById(id);
if (!el) throw new Error(`Element #${id} not found in DOM`);
return el;
}
/**
* Get an element by ID, returning null if not found.
* Typed alternative to `document.getElementById` that avoids
* the `HTMLElement | null` ambiguity in non-null assertion contexts.
*/
export function queryEl(id: string): HTMLElement | null {
return document.getElementById(id);
}

View File

@@ -63,7 +63,14 @@ function _invalidateAndReload(entityType) {
if (oldData === newData) return;
if (Array.isArray(oldData) && Array.isArray(newData) &&
oldData.length === newData.length &&
JSON.stringify(oldData) === JSON.stringify(newData)) return;
oldData.every((item, i) => {
const other = newData[i];
return item === other || (
item && other &&
item.id === other.id &&
item.updated_at === other.updated_at
);
})) return;
const loader = ENTITY_LOADER_MAP[entityType];
if (loader) {

View File

@@ -102,8 +102,8 @@ export class EntityPalette {
this._resolve = resolve;
this._items = items || [];
this._currentValue = current;
this._allowNone = allowNone;
this._noneLabel = noneLabel;
this._allowNone = allowNone ?? false;
this._noneLabel = noneLabel ?? '';
this._input.placeholder = placeholder || '';
this._input.value = '';
@@ -219,7 +219,7 @@ export class EntitySelect {
this._select = target;
this._getItems = getItems;
this._placeholder = placeholder || '';
this._onChange = onChange;
this._onChange = onChange ?? null;
this._allowNone = allowNone || false;
this._noneLabel = noneLabel || '—';
this._items = getItems();

View File

@@ -22,7 +22,7 @@ export function startEventsWS() {
if (!apiKey) return;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`;
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey)}`;
try {
_ws = new WebSocket(url);

View File

@@ -81,7 +81,7 @@ export class FilterListManager {
const select = document.getElementById(this._selectId) as HTMLSelectElement;
const filterDefs = this._getFilterDefs();
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
const items = [];
const items: { value: string; icon: string; label: string; desc: string }[] = [];
for (const f of filterDefs) {
const name = this._getFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;

View File

@@ -124,7 +124,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
if (!entry) return false;
const url = entry.endpoint.replace('{id}', targetId);
const url = entry.endpoint!.replace('{id}', targetId);
const body = { [field]: newSourceId };
try {

View File

@@ -77,7 +77,7 @@ function _renderEdge(edge: GraphEdge): SVGElement {
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
// Always use port-aware bezier — ELK routes without port knowledge so
// its bend points don't align with actual port positions.
const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
const d = _defaultBezier(fromNode!, toNode!, edge.fromPortY, edge.toPortY);
const path = svgEl('path', {
class: cssClass,
@@ -201,8 +201,8 @@ export function highlightChain(edgeGroup: SVGGElement, nodeId: string, edges: Gr
const chain = new Set([...upstream, ...downstream]);
edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
const from = path.getAttribute('data-from');
const to = path.getAttribute('data-to');
const from = path.getAttribute('data-from') ?? '';
const to = path.getAttribute('data-to') ?? '';
const inChain = chain.has(from) && chain.has(to);
path.classList.toggle('highlighted', inChain);
path.classList.toggle('dimmed', !inChain);
@@ -210,8 +210,8 @@ export function highlightChain(edgeGroup: SVGGElement, nodeId: string, edges: Gr
// Dim flow-dot groups on non-chain edges
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
const from = g.getAttribute('data-from');
const to = g.getAttribute('data-to');
const from = g.getAttribute('data-from') ?? '';
const to = g.getAttribute('data-to') ?? '';
(g as SVGElement).style.opacity = (chain.has(from) && chain.has(to)) ? '' : '0.12';
});
@@ -257,7 +257,7 @@ export function updateEdgesForNode(group: SVGGElement, nodeId: string, nodeMap:
const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) continue;
const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
const d = _defaultBezier(fromNode!, toNode!, edge.fromPortY, edge.toPortY);
group.querySelectorAll(`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(pathEl => {
if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) {
pathEl.setAttribute('d', d);

View File

@@ -101,7 +101,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
const nodeMap = new Map();
const nodeById = new Map(nodeList.map(n => [n.id, n]));
for (const child of layout.children) {
for (const child of layout.children!) {
const src = nodeById.get(child.id);
if (src) {
nodeMap.set(child.id, {
@@ -115,7 +115,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
}
// Build edge paths from layout
const edges = [];
const edges: any[] = [];
for (let i = 0; i < edgeList.length; i++) {
const layoutEdge = layout.edges?.[i];
const srcEdge = edgeList[i];
@@ -123,7 +123,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
const toNode = nodeMap.get(srcEdge.to);
if (!fromNode || !toNode) continue;
let points = null;
let points: any[] | null = null;
if ((layoutEdge as any)?.sections?.[0]) {
const sec = (layoutEdge as any).sections[0];
points = [sec.startPoint, ...(sec.bendPoints || []), sec.endPoint];
@@ -220,8 +220,8 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
function addEdge(from: string, to: string, field: string, label: string = ''): void {
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
const type = edgeType(
nodes.find(n => n.id === from)?.kind,
nodes.find(n => n.id === to)?.kind,
nodes.find(n => n.id === from)?.kind ?? '',
nodes.find(n => n.id === to)?.kind ?? '',
field
);
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable

View File

@@ -176,7 +176,7 @@ function _openNodeColorPicker(node: GraphNode, e: MouseEvent): void {
cpOverlay.innerHTML = createColorPicker({
id: pickerId,
currentColor: curColor || ENTITY_COLORS[node.kind] || '#666',
onPick: null,
onPick: undefined,
anchor: 'left',
showReset: true,
resetColor: '#808080',
@@ -475,7 +475,7 @@ function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallb
bg.appendChild(iconG);
} else {
const txt = svgEl('text', { x: bx + btnSize / 2, y: by + btnSize / 2 });
txt.textContent = btn.icon;
txt.textContent = btn.icon ?? '';
bg.appendChild(txt);
}
const btnTip = svgEl('title');
@@ -543,7 +543,7 @@ export function patchNodeRunning(group: SVGGElement, node: GraphNode): void {
/**
* Highlight a single node (add class, scroll to).
*/
export function highlightNode(group: SVGGElement, nodeId: string, cls: string = 'search-match'): Element | null {
export function highlightNode(group: SVGGElement, nodeId: string | null, cls: string = 'search-match'): Element | null {
// Remove existing highlights
group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls));
const node = group.querySelector(`.graph-node[data-id="${nodeId}"]`);
@@ -572,6 +572,6 @@ export function markOrphans(group: SVGGElement, nodeMap: Map<string, GraphNode>,
*/
export function updateSelection(group: SVGGElement, selectedIds: Set<string>): void {
group.querySelectorAll('.graph-node').forEach(n => {
n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id')));
n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id') ?? ''));
});
}

View File

@@ -104,7 +104,7 @@ function updateLocaleSelect() {
export function updateAllText() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const key = el.getAttribute('data-i18n')!;
el.textContent = t(key);
});
@@ -119,7 +119,7 @@ export function updateAllText() {
});
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.getAttribute('data-i18n-aria-label');
const key = el.getAttribute('data-i18n-aria-label')!;
el.setAttribute('aria-label', t(key));
});

View File

@@ -36,8 +36,8 @@ export function navigateToCard(tab: string, subTab: string | null, sectionKey: s
// Expand section if collapsed
if (sectionKey) {
const content = document.querySelector(`[data-cs-content="${sectionKey}"]`) as HTMLElement | null;
const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`);
const content = document.querySelector(`[data-cs-content="${CSS.escape(sectionKey)}"]`) as HTMLElement | null;
const header = document.querySelector(`[data-cs-toggle="${CSS.escape(sectionKey)}"]`);
if (content && content.style.display === 'none') {
content.style.display = '';
const chevron = header?.querySelector('.cs-chevron') as HTMLElement | null;
@@ -54,7 +54,7 @@ export function navigateToCard(tab: string, subTab: string | null, sectionKey: s
const scope = tabPanel || document;
// Check if card already exists (data previously loaded)
const existing = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
const existing = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
if (existing) {
_highlightCard(existing);
return;
@@ -111,12 +111,12 @@ function _showDimOverlay(duration: number) {
function _waitForCard(cardAttr: string, cardValue: string, timeout: number, scope: Document | HTMLElement = document) {
const root = scope === document ? document.body : scope as HTMLElement;
return new Promise(resolve => {
const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
const card = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
if (card) { resolve(card); return; }
const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
const observer = new MutationObserver(() => {
const el = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
const el = scope.querySelector(`[${cardAttr}="${CSS.escape(cardValue)}"]`);
if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); }
});
observer.observe(root, { childList: true, subtree: true });

View File

@@ -12,7 +12,7 @@ import type {
ValueSource, AudioSource, PictureSource, ScenePreset,
SyncClock, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
} from '../types.ts';
export let apiKey: string | null = null;
@@ -42,7 +42,7 @@ export let _displayPickerSelectedIndex: number | null = null;
export function set_displayPickerSelectedIndex(v: number | null) { _displayPickerSelectedIndex = v; }
// Calibration
export const calibrationTestState: Record<string, any> = {};
export const calibrationTestState: Record<string, Set<string>> = {};
export const EDGE_TEST_COLORS: Record<string, number[]> = {
top: [255, 0, 0],
@@ -65,8 +65,8 @@ export function updateDeviceBrightness(deviceId: string, value: number) {
export let _discoveryScanRunning = false;
export function set_discoveryScanRunning(v: boolean) { _discoveryScanRunning = v; }
export let _discoveryCache: Record<string, any> = {};
export function set_discoveryCache(v: Record<string, any>) { _discoveryCache = v; }
export let _discoveryCache: Record<string, any[]> = {};
export function set_discoveryCache(v: Record<string, any[]>) { _discoveryCache = v; }
// Streams / templates state
export let _cachedStreams: PictureSource[] = [];
@@ -90,15 +90,15 @@ export let _templateNameManuallyEdited = false;
export function set_templateNameManuallyEdited(v: boolean) { _templateNameManuallyEdited = v; }
// PP template state
export let _modalFilters: any[] = [];
export function set_modalFilters(v: any[]) { _modalFilters = v; }
export let _modalFilters: FilterInstance[] = [];
export function set_modalFilters(v: FilterInstance[]) { _modalFilters = v; }
export let _ppTemplateNameManuallyEdited = false;
export function set_ppTemplateNameManuallyEdited(v: boolean) { _ppTemplateNameManuallyEdited = v; }
// CSPT (Color Strip Processing Template) state
export let _csptModalFilters: any[] = [];
export function set_csptModalFilters(v: any[]) { _csptModalFilters = v; }
export let _csptModalFilters: FilterInstance[] = [];
export function set_csptModalFilters(v: FilterInstance[]) { _csptModalFilters = v; }
export let _csptNameManuallyEdited = false;
export function set_csptNameManuallyEdited(v: boolean) { _csptNameManuallyEdited = v; }
@@ -135,8 +135,17 @@ export const kcWebSockets: Record<string, WebSocket> = {};
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
// Tutorial state
export let activeTutorial: any = null;
export function setActiveTutorial(v: any) { activeTutorial = v; }
export interface TutorialState {
steps: { selector: string; textKey: string; position: string; global?: boolean }[];
overlay: HTMLElement;
mode: string;
step: number;
resolveTarget: (step: { selector: string; textKey: string; position: string; global?: boolean }) => Element | null;
container: Element | null;
onClose: (() => void) | null;
}
export let activeTutorial: TutorialState | null = null;
export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; }
// Confirm modal
export let confirmResolve: ((value: boolean) => void) | null = null;
@@ -162,8 +171,8 @@ export function setDashboardPollInterval(v: number) {
}
// Pattern template editor state
export let patternEditorRects: any[] = [];
export function setPatternEditorRects(v: any[]) { patternEditorRects = v; }
export let patternEditorRects: KeyColorRectangle[] = [];
export function setPatternEditorRects(v: KeyColorRectangle[]) { patternEditorRects = v; }
export let patternEditorSelectedIdx = -1;
export function setPatternEditorSelectedIdx(v: number) { patternEditorSelectedIdx = v; }
@@ -177,8 +186,8 @@ export function setPatternCanvasDragMode(v: string | null) { patternCanvasDragMo
export let patternCanvasDragStart: { x?: number; y?: number; mx?: number; my?: number } | null = null;
export function setPatternCanvasDragStart(v: { x?: number; y?: number; mx?: number; my?: number } | null) { patternCanvasDragStart = v; }
export let patternCanvasDragOrigRect: any = null;
export function setPatternCanvasDragOrigRect(v: any) { patternCanvasDragOrigRect = v; }
export let patternCanvasDragOrigRect: KeyColorRectangle | null = null;
export function setPatternCanvasDragOrigRect(v: KeyColorRectangle | null) { patternCanvasDragOrigRect = v; }
export let patternEditorHoveredIdx = -1;
export function setPatternEditorHoveredIdx(v: number) { patternEditorHoveredIdx = v; }

View File

@@ -72,8 +72,8 @@ export class TagInput {
* @param {object} [opts]
* @param {string} [opts.placeholder] Placeholder text for input
*/
constructor(container: HTMLElement, opts: any = {}) {
this._container = container;
constructor(container: HTMLElement | null, opts: any = {}) {
this._container = container!;
this._tags = [];
this._placeholder = opts.placeholder || 'Add tag...';
this._dropdownVisible = false;
@@ -212,13 +212,13 @@ export class TagInput {
this._dropdownEl.innerHTML = suggestions.map((tag, i) =>
`<div class="tag-dropdown-item${i === 0 ? ' tag-dropdown-active' : ''}" data-tag="${_escapeHtml(tag)}">${_escapeHtml(tag)}</div>`
).join('');
this._dropdownEl.style.display = 'block';
this._dropdownEl.classList.add('open');
this._dropdownVisible = true;
this._selectedIdx = 0;
}
_hideDropdown() {
this._dropdownEl.style.display = 'none';
this._dropdownEl.classList.remove('open');
this._dropdownVisible = false;
this._selectedIdx = -1;
}

View File

@@ -3,6 +3,7 @@
*/
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts';
import { API_BASE, getHeaders } from './api.ts';
import { t } from './i18n.ts';
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
@@ -207,7 +208,6 @@ export function closeConfirmModal(result: boolean) {
export async function openFullImageLightbox(imageSource: string) {
try {
const { API_BASE, getHeaders } = await import('./api.js');
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
headers: getHeaders()
});
@@ -323,7 +323,7 @@ export function showOverlaySpinner(text: string, duration = 0) {
progressCircle.style.strokeDashoffset = String(offset);
progressPercentage.textContent = `${percentage}%`;
if (progress >= 1) {
clearInterval(window.overlaySpinnerTimer);
clearInterval(window.overlaySpinnerTimer!);
window.overlaySpinnerTimer = null;
}
}, 100);

View File

@@ -280,7 +280,7 @@ export function selectCalibrationLine(idx: number): void {
const prev = _state.selectedLine;
_state.selectedLine = idx;
// Update selection in-place without rebuilding the list DOM
const container = document.getElementById('advcal-line-list');
const container = document.getElementById('advcal-line-list')!;
const items = container.querySelectorAll('.advcal-line-item');
if (prev >= 0 && prev < items.length) items[prev].classList.remove('selected');
if (idx >= 0 && idx < items.length) items[idx].classList.add('selected');
@@ -361,14 +361,14 @@ function _buildMonitorLayout(psList: any[], cssId: string | null): void {
// Load saved positions from localStorage
const savedKey = `advcal_positions_${cssId}`;
let saved = {};
try { saved = JSON.parse(localStorage.getItem(savedKey)) || {}; } catch { /* ignore */ }
try { saved = JSON.parse(localStorage.getItem(savedKey) ?? '{}') || {}; } catch { /* ignore */ }
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement;
const canvasW = canvas.width;
const canvasH = canvas.height;
// Default layout: arrange monitors in a row
const monitors = [];
const monitors: any[] = [];
const padding = 20;
const maxMonW = (canvasW - padding * 2) / Math.max(psList.length, 1) - 10;
const monH = canvasH * 0.6;
@@ -423,7 +423,7 @@ function _placeNewMonitor(): void {
function _updateTotalLeds(): void {
const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
const el = document.getElementById('advcal-total-leds');
const el = document.getElementById('advcal-total-leds')!;
if (_state.totalLedCount > 0) {
el.textContent = `${used}/${_state.totalLedCount}`;
el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
@@ -436,7 +436,7 @@ function _updateTotalLeds(): void {
/* ── Line list rendering ────────────────────────────────────── */
function _renderLineList(): void {
const container = document.getElementById('advcal-line-list');
const container = document.getElementById('advcal-line-list')!;
container.innerHTML = '';
_state.lines.forEach((line, i) => {
@@ -470,7 +470,7 @@ function _renderLineList(): void {
}
function _showLineProps(): void {
const propsEl = document.getElementById('advcal-line-props');
const propsEl = document.getElementById('advcal-line-props')!;
const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) {
propsEl.style.display = 'none';
@@ -553,7 +553,7 @@ function _fitView(): void {
function _renderCanvas(): void {
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const W = canvas.width;
const H = canvas.height;
@@ -679,7 +679,7 @@ function _drawLineTicks(ctx: CanvasRenderingContext2D, line: CalibrationLine, x1
return (line.reverse ? (1 - f) : f) * edgeLen;
};
const placed = [];
const placed: number[] = [];
// Place intermediate labels at nice steps
for (let i = 0; i < count; i++) {

View File

@@ -439,7 +439,7 @@ function _sizeCanvas(canvas: HTMLCanvasElement) {
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d').scale(dpr, dpr);
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _renderLoop() {
@@ -449,11 +449,40 @@ function _renderLoop() {
}
}
// ── Event delegation for audio source card actions ──
const _audioSourceActions: Record<string, (id: string) => void> = {
'test-audio': testAudioSource,
'clone-audio': cloneAudioSource,
'edit-audio': editAudioSource,
};
export function initAudioSourceDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action || !id) return;
// Only handle audio-source actions (prefixed with audio-)
const handler = _audioSourceActions[action];
if (handler) {
// Verify we're inside an audio source section
const section = btn.closest<HTMLElement>('[data-card-section="audio-multi"], [data-card-section="audio-mono"]');
if (!section) return;
e.stopPropagation();
handler(id);
}
});
}
function _renderAudioSpectrum() {
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;

View File

@@ -18,7 +18,7 @@ import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard } from './scene-presets.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
let _automationTagsInput: any = null;
@@ -158,7 +158,7 @@ export async function loadAutomations() {
} catch (error: any) {
if (error.isAuth) return;
console.error('Failed to load automations:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
container.innerHTML = `<p class="error-message">${escapeHtml(error.message)}</p>`;
} finally {
set_automationsLoading(false);
setTabRefreshing('automations-content', false);
@@ -194,6 +194,9 @@ function renderAutomations(automations: any, sceneMap: any) {
container.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
// Event delegation for scene preset card actions
initScenePresetDelegation(container);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {

View File

@@ -138,7 +138,7 @@ export async function showCalibration(deviceId: any) {
try {
const [response, displays] = await Promise.all([
fetchWithAuth(`/devices/${deviceId}`),
displaysCache.fetch().catch(() => []),
displaysCache.fetch().catch((): any[] => []),
]);
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
@@ -235,7 +235,7 @@ export async function showCSSCalibration(cssId: any) {
try {
const [cssSources, devices] = await Promise.all([
colorStripSourcesCache.fetch(),
devicesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
]);
const source = cssSources.find((s: any) => s.id === cssId);
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }

View File

@@ -0,0 +1,413 @@
/**
* Color Strip Sources — Composite layer helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { escapeHtml } from '../core/api.ts';
import { _cachedValueSources, _cachedCSPTemplates } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import {
getColorStripIcon, getValueSourceIcon,
ICON_SPARKLES,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Composite layer state ────────────────────────────────────── */
let _compositeLayers: any[] = [];
let _compositeAvailableSources: any[] = []; // non-composite sources for layer dropdowns
let _compositeSourceEntitySelects: any[] = [];
let _compositeBrightnessEntitySelects: any[] = [];
let _compositeBlendIconSelects: any[] = [];
let _compositeCSPTEntitySelects: any[] = [];
/** Return current composite layers array (for dirty-check snapshot). */
export function compositeGetRawLayers() {
return _compositeLayers;
}
export function compositeSetAvailableSources(sources: any[]) {
_compositeAvailableSources = sources;
}
export function compositeGetAvailableSources() {
return _compositeAvailableSources;
}
export function compositeDestroyEntitySelects() {
_compositeSourceEntitySelects.forEach(es => es.destroy());
_compositeSourceEntitySelects = [];
_compositeBrightnessEntitySelects.forEach(es => es.destroy());
_compositeBrightnessEntitySelects = [];
_compositeBlendIconSelects.forEach(is => is.destroy());
_compositeBlendIconSelects = [];
_compositeCSPTEntitySelects.forEach(es => es.destroy());
_compositeCSPTEntitySelects = [];
}
function _getCompositeBlendItems() {
return [
{ value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') },
{ value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') },
{ value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') },
{ value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') },
{ value: 'override', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') },
];
}
function _getCompositeSourceItems() {
return _compositeAvailableSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
}));
}
function _getCompositeBrightnessItems() {
return (_cachedValueSources || []).map(v => ({
value: v.id,
label: v.name,
icon: getValueSourceIcon(v.source_type),
}));
}
function _getCompositeCSPTItems() {
return (_cachedCSPTemplates || []).map(tmpl => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_SPARKLES,
}));
}
export function compositeRenderList() {
const list = document.getElementById('composite-layers-list') as HTMLElement | null;
if (!list) return;
compositeDestroyEntitySelects();
const vsList = _cachedValueSources || [];
list.innerHTML = _compositeLayers.map((layer, i) => {
const srcOptions = _compositeAvailableSources.map(s =>
`<option value="${s.id}"${layer.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
const vsOptions = `<option value="">${t('color_strip.composite.brightness.none')}</option>` +
vsList.map(v =>
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
).join('');
const csptList = _cachedCSPTemplates || [];
const csptOptions = `<option value="">${t('common.none_no_cspt')}</option>` +
csptList.map(tmpl =>
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item" data-layer-index="${i}">
<div class="composite-layer-row">
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
<select class="composite-layer-blend" data-idx="${i}">
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-opacity-label">
<span>${t('color_strip.composite.opacity')}:</span>
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
</label>
<input type="range" class="composite-layer-opacity" data-idx="${i}"
min="0" max="1" step="0.05" value="${layer.opacity}">
<label class="settings-toggle composite-layer-toggle">
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
<span class="settings-toggle-slider"></span>
</label>
${canRemove
? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
onclick="compositeRemoveLayer(${i})">&#x2715;</button>`
: ''}
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.brightness')}:</span>
</label>
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.processing')}:</span>
</label>
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
</div>
</div>
`;
}).join('');
// Wire up live opacity display
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
el.addEventListener('input', () => {
const val = parseFloat(el.value);
(el.closest('.composite-layer-row')!.querySelector('.composite-opacity-val') as HTMLElement).textContent = val.toFixed(2);
});
});
// Attach IconSelect to each layer's blend mode dropdown
const blendItems = _getCompositeBlendItems();
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
const is = new IconSelect({ target: sel, items: blendItems, columns: 2 });
_compositeBlendIconSelects.push(is);
});
// Attach EntitySelect to each layer's source dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-source').forEach(sel => {
_compositeSourceEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeSourceItems,
placeholder: t('palette.search'),
}));
});
// Attach EntitySelect to each layer's brightness dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness').forEach(sel => {
_compositeBrightnessEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeBrightnessItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('color_strip.composite.brightness.none'),
}));
});
// Attach EntitySelect to each layer's CSPT dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt').forEach(sel => {
_compositeCSPTEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeCSPTItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('common.none_no_cspt'),
}));
});
_initCompositeLayerDrag(list);
}
export function compositeAddLayer() {
_compositeLayersSyncFromDom();
_compositeLayers.push({
source_id: _compositeAvailableSources.length > 0 ? _compositeAvailableSources[0].id : '',
blend_mode: 'normal',
opacity: 1.0,
enabled: true,
brightness_source_id: null,
processing_template_id: null,
});
compositeRenderList();
}
export function compositeRemoveLayer(i: number) {
_compositeLayersSyncFromDom();
if (_compositeLayers.length <= 1) return;
_compositeLayers.splice(i, 1);
compositeRenderList();
}
function _compositeLayersSyncFromDom() {
const list = document.getElementById('composite-layers-list') as HTMLElement | null;
if (!list) return;
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
const opacities = list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity');
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
if (srcs.length === _compositeLayers.length) {
for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[i].value;
_compositeLayers[i].blend_mode = blends[i].value;
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
_compositeLayers[i].enabled = enableds[i].checked;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
}
}
}
/* ── Composite layer drag-to-reorder ── */
const _COMPOSITE_DRAG_THRESHOLD = 5;
let _compositeLayerDragState: any = null;
function _initCompositeLayerDrag(list: any) {
// Guard against stacking listeners across re-renders (the list DOM node persists).
if (list._compositeDragBound) return;
list._compositeDragBound = true;
list.addEventListener('pointerdown', (e: any) => {
const handle = e.target.closest('.composite-layer-drag-handle');
if (!handle) return;
const item = handle.closest('.composite-layer-item');
if (!item) return;
e.preventDefault();
e.stopPropagation();
const fromIndex = parseInt(item.dataset.layerIndex, 10);
_compositeLayerDragState = {
item,
list,
startY: e.clientY,
started: false,
clone: null,
placeholder: null,
offsetY: 0,
fromIndex,
scrollRaf: null,
};
const onMove = (ev: any) => _onCompositeLayerDragMove(ev);
const cleanup = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', cleanup);
document.removeEventListener('pointercancel', cleanup);
_onCompositeLayerDragEnd();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', cleanup);
document.addEventListener('pointercancel', cleanup);
}, { capture: false });
}
function _onCompositeLayerDragMove(e: any) {
const ds = _compositeLayerDragState;
if (!ds) return;
if (!ds.started) {
if (Math.abs(e.clientY - ds.startY) < _COMPOSITE_DRAG_THRESHOLD) return;
_startCompositeLayerDrag(ds, e);
}
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
const items = ds.list.querySelectorAll('.composite-layer-item');
for (const it of items) {
if (it.style.display === 'none') continue;
const r = it.getBoundingClientRect();
if (e.clientY >= r.top && e.clientY <= r.bottom) {
const before = e.clientY < r.top + r.height / 2;
if (it === ds.lastTarget && before === ds.lastBefore) break;
ds.lastTarget = it;
ds.lastBefore = before;
if (before) {
ds.list.insertBefore(ds.placeholder, it);
} else {
ds.list.insertBefore(ds.placeholder, it.nextSibling);
}
break;
}
}
// Auto-scroll near modal edges
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
const modal = ds.list.closest('.modal-body');
if (modal) {
const EDGE = 60, SPEED = 12;
const mr = modal.getBoundingClientRect();
let speed = 0;
if (e.clientY < mr.top + EDGE) speed = -SPEED;
else if (e.clientY > mr.bottom - EDGE) speed = SPEED;
if (speed !== 0) {
const scroll = () => { modal.scrollTop += speed; ds.scrollRaf = requestAnimationFrame(scroll); };
ds.scrollRaf = requestAnimationFrame(scroll);
}
}
}
function _startCompositeLayerDrag(ds: any, e: any) {
ds.started = true;
const rect = ds.item.getBoundingClientRect();
const clone = ds.item.cloneNode(true);
clone.className = ds.item.className + ' composite-layer-drag-clone';
clone.style.width = rect.width + 'px';
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
document.body.appendChild(clone);
ds.clone = clone;
ds.offsetY = e.clientY - rect.top;
const placeholder = document.createElement('div');
placeholder.className = 'composite-layer-drag-placeholder';
placeholder.style.height = rect.height + 'px';
ds.item.parentNode.insertBefore(placeholder, ds.item);
ds.placeholder = placeholder;
ds.item.style.display = 'none';
document.body.classList.add('composite-layer-dragging');
}
function _onCompositeLayerDragEnd() {
const ds = _compositeLayerDragState;
_compositeLayerDragState = null;
if (!ds || !ds.started) return;
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
// Determine new index from placeholder position
let toIndex = 0;
for (const child of ds.list.children) {
if (child === ds.placeholder) break;
if (child.classList.contains('composite-layer-item') && child.style.display !== 'none') {
toIndex++;
}
}
// Cleanup DOM
ds.item.style.display = '';
ds.placeholder.remove();
ds.clone.remove();
document.body.classList.remove('composite-layer-dragging');
// Sync current DOM values before reordering
_compositeLayersSyncFromDom();
// Reorder array and re-render
if (toIndex !== ds.fromIndex) {
const [moved] = _compositeLayers.splice(ds.fromIndex, 1);
_compositeLayers.splice(toIndex, 0, moved);
compositeRenderList();
}
}
export function compositeGetLayers() {
_compositeLayersSyncFromDom();
return _compositeLayers.map(l => {
const layer: any = {
source_id: l.source_id,
blend_mode: l.blend_mode,
opacity: l.opacity,
enabled: l.enabled,
};
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
return layer;
});
}
export function loadCompositeState(css: any) {
const raw = css && css.layers;
_compositeLayers = (raw && raw.length > 0)
? raw.map((l: any) => ({
source_id: l.source_id || '',
blend_mode: l.blend_mode || 'normal',
opacity: l.opacity != null ? l.opacity : 1.0,
enabled: l.enabled != null ? l.enabled : true,
brightness_source_id: l.brightness_source_id || null,
processing_template_id: l.processing_template_id || null,
}))
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
compositeRenderList();
}

View File

@@ -0,0 +1,273 @@
/**
* Color Strip Sources — Notification helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import {
ICON_SEARCH,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { getBaseOrigin } from './settings.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Notification state ───────────────────────────────────────── */
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}]
/** Return current app colors array (for dirty-check snapshot). */
export function notificationGetRawAppColors() {
return _notificationAppColors;
}
let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null;
export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'flash', icon: _icon(P.zap), label: t('color_strip.notification.effect.flash'), desc: t('color_strip.notification.effect.flash.desc') },
{ value: 'pulse', icon: _icon(P.activity), label: t('color_strip.notification.effect.pulse'), desc: t('color_strip.notification.effect.pulse.desc') },
{ value: 'sweep', icon: _icon(P.fastForward), label: t('color_strip.notification.effect.sweep'), desc: t('color_strip.notification.effect.sweep.desc') },
];
if (_notificationEffectIconSelect) { _notificationEffectIconSelect.updateItems(items); return; }
_notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
export function ensureNotificationFilterModeIconSelect() {
const sel = document.getElementById('css-editor-notification-filter-mode') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'off', icon: _icon(P.globe), label: t('color_strip.notification.filter_mode.off'), desc: t('color_strip.notification.filter_mode.off.desc') },
{ value: 'whitelist', icon: _icon(P.circleCheck), label: t('color_strip.notification.filter_mode.whitelist'), desc: t('color_strip.notification.filter_mode.whitelist.desc') },
{ value: 'blacklist', icon: _icon(P.eyeOff), label: t('color_strip.notification.filter_mode.blacklist'), desc: t('color_strip.notification.filter_mode.blacklist.desc') },
];
if (_notificationFilterModeIconSelect) { _notificationFilterModeIconSelect.updateItems(items); return; }
_notificationFilterModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
export function onNotificationFilterModeChange() {
const mode = (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value;
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
}
function _notificationAppColorsRenderList() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="notif-app-color-row">
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
<button type="button" class="notif-app-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="notif-app-color-remove"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div>
`).join('');
// Wire up browse buttons to open process palette
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx!);
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`);
if (!nameInput) return;
const picked = await NotificationAppPalette.pick({
current: nameInput.value,
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps...',
});
if (picked !== undefined) {
nameInput.value = picked;
_notificationAppColorsSyncFromDom();
}
});
});
}
export function notificationAddAppColor() {
_notificationAppColorsSyncFromDom();
_notificationAppColors.push({ app: '', color: '#ffffff' });
_notificationAppColorsRenderList();
}
export function notificationRemoveAppColor(i: number) {
_notificationAppColorsSyncFromDom();
_notificationAppColors.splice(i, 1);
_notificationAppColorsRenderList();
}
export async function testNotification(sourceId: string) {
try {
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast(err.detail || t('color_strip.notification.test.error'), 'error');
return;
}
const data = await resp.json();
if (data.streams_notified > 0) {
showToast(t('color_strip.notification.test.ok'), 'success');
} else {
showToast(t('color_strip.notification.test.no_streams'), 'warning');
}
} catch {
showToast(t('color_strip.notification.test.error'), 'error');
}
}
// ── OS Notification History Modal ─────────────────────────────────────────
export function showNotificationHistory() {
const modal = document.getElementById('notification-history-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeNotificationHistory(); };
_loadNotificationHistory();
}
export function closeNotificationHistory() {
const modal = document.getElementById('notification-history-modal') as HTMLElement | null;
if (modal) modal.style.display = 'none';
}
export async function refreshNotificationHistory() {
await _loadNotificationHistory();
}
async function _loadNotificationHistory() {
const list = document.getElementById('notification-history-list') as HTMLElement | null;
const status = document.getElementById('notification-history-status') as HTMLElement | null;
if (!list) return;
try {
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!;
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.available) {
list.innerHTML = '';
if (status) {
status.textContent = t('color_strip.notification.history.unavailable');
status.style.display = '';
}
return;
}
if (status) status.style.display = 'none';
const history = data.history || [];
if (history.length === 0) {
list.innerHTML = `<div class="notif-history-empty">${t('color_strip.notification.history.empty')}</div>`;
return;
}
list.innerHTML = history.map((entry: any) => {
const appName = entry.app || t('color_strip.notification.history.unknown_app');
const timeStr = new Date(entry.time * 1000).toLocaleString();
const fired = entry.fired ?? 0;
const filtered = entry.filtered ?? 0;
const firedBadge = fired > 0
? `<span class="notif-history-badge notif-history-badge--fired" title="${t('color_strip.notification.history.fired')}">${fired}</span>`
: '';
const filteredBadge = filtered > 0
? `<span class="notif-history-badge notif-history-badge--filtered" title="${t('color_strip.notification.history.filtered')}">${filtered}</span>`
: '';
return `<div class="notif-history-row">
<div class="notif-history-app" title="${escapeHtml(appName)}">${escapeHtml(appName)}</div>
<div class="notif-history-time">${timeStr}</div>
<div class="notif-history-badges">${firedBadge}${filteredBadge}</div>
</div>`;
}).join('');
} catch (err: any) {
console.error('Failed to load notification history:', err);
if (status) {
status.textContent = t('color_strip.notification.history.error');
status.style.display = '';
}
list.innerHTML = '';
}
}
function _notificationAppColorsSyncFromDom() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name');
const colors = list.querySelectorAll<HTMLInputElement>('.notif-app-color');
if (names.length === _notificationAppColors.length) {
for (let i = 0; i < names.length; i++) {
_notificationAppColors[i].app = names[i].value;
_notificationAppColors[i].color = colors[i].value;
}
}
}
export function notificationGetAppColorsDict() {
_notificationAppColorsSyncFromDom();
const dict: Record<string, any> = {};
for (const entry of _notificationAppColors) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
export function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
const dur = css.duration_ms ?? 1500;
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = dur;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = dur;
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = (css.app_filter_list || []).join('\n');
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
// App colors dict -> list
const ac = css.app_colors || {};
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
_notificationAppColorsRenderList();
showNotificationEndpoint(css.id);
}
export function resetNotificationState() {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
_notificationAppColors = [];
_notificationAppColorsRenderList();
showNotificationEndpoint(null);
}
function _attachNotificationProcessPicker() {
const container = document.getElementById('css-editor-notification-filter-picker-container') as HTMLElement | null;
const textarea = document.getElementById('css-editor-notification-filter-list') as HTMLTextAreaElement | null;
if (container && textarea) attachNotificationAppPicker(container, textarea);
}
export function showNotificationEndpoint(cssId: any) {
const el = document.getElementById('css-editor-notification-endpoint') as HTMLElement | null;
if (!el) return;
if (!cssId) {
el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
return;
}
const base = `${getBaseOrigin()}/api/v1`;
const url = `${base}/color-strip-sources/${cssId}/notify`;
el.innerHTML = `
<small class="endpoint-label">POST</small>
<div class="ws-url-row"><input type="text" value="${url}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
`;
}

View File

@@ -0,0 +1,907 @@
/**
* Color Strip Sources — Test / Preview modal (WebSocket strip renderer).
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import {
getColorStripIcon,
ICON_WARNING, ICON_SUN_DIM,
} from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight',
]);
function _collectPreviewConfig() {
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
if (!_PREVIEW_TYPES.has(sourceType)) return null;
let config: any;
if (sourceType === 'static') {
config = { source_type: 'static', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
} else if (sourceType === 'gradient') {
const stops = getGradientStops();
if (stops.length < 2) return null;
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload() };
} else if (sourceType === 'color_cycle') {
const colors = _colorCycleGetColors();
if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') {
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
if (config.effect_type === 'meteor') { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
} else if (sourceType === 'daylight') {
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value) };
} else if (sourceType === 'candlelight') {
config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value) };
}
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
if (clockEl && clockEl.value) config.clock_id = clockEl.value;
return config;
}
/**
* Open the existing Test Preview modal from the CSS editor.
* For saved sources, uses the normal test endpoint.
* For unsaved/self-contained types, uses the transient preview endpoint.
*/
export function previewCSSFromEditor() {
// Always use transient preview with current form values
const config = _collectPreviewConfig();
if (!config) {
// Non-previewable type (picture, composite, etc.) — fall back to saved source test
const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
if (cssId) { testColorStrip(cssId); return; }
showToast(t('color_strip.preview.unsupported'), 'info');
return;
}
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
_cssTestTransientConfig = config;
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = 'none';
_openTestModal('__preview__');
}
/** Store transient config so _cssTestConnect and applyCssTestSettings can use it. */
let _cssTestTransientConfig: any = null;
/* ── Test / Preview ───────────────────────────────────────────── */
const _CSS_TEST_LED_KEY = 'css_test_led_count';
const _CSS_TEST_FPS_KEY = 'css_test_fps';
let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
let _cssTestMeta: any = null;
let _cssTestSourceId: string | null = null;
let _cssTestIsComposite: boolean = false;
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
let _cssTestGeneration: number = 0; // bumped on each connect to ignore stale WS messages
let _cssTestNotificationIds: string[] = []; // notification source IDs to fire (self or composite layers)
let _cssTestCSPTMode: boolean = false; // true when testing a CSPT template
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput: boolean = false;
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
let _cssTestFpsChart: any = null;
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
let _csptTestInputEntitySelect: any = null;
function _getCssTestLedCount() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY) ?? '', 10);
return (stored > 0 && stored <= 2000) ? stored : 100;
}
function _getCssTestFps() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_FPS_KEY) ?? '', 10);
return (stored >= 1 && stored <= 60) ? stored : 20;
}
function _populateCssTestSourceSelector(preselectId: any) {
const sources = (colorStripSourcesCache.data || []) as any[];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement;
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}"${s.id === preselectId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => ((colorStripSourcesCache.data || []) as any[])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
}
export function testColorStrip(sourceId: string) {
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
// Detect api_input type
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === sourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
// Populate input source selector with current source preselected
_populateCssTestSourceSelector(sourceId);
_openTestModal(sourceId);
}
export async function testCSPT(templateId: string) {
_cssTestCSPTMode = true;
_cssTestCSPTId = templateId;
// Populate input source selector
await colorStripSourcesCache.fetch();
_populateCssTestSourceSelector(null);
const sel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement;
const inputId = sel.value;
if (!inputId) {
showToast(t('color_strip.processed.error.no_input'), 'error');
return;
}
_openTestModal(inputId);
}
function _openTestModal(sourceId: string) {
// Clean up any previous session fully
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestIsComposite = false;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); };
_cssTestSourceId = sourceId;
// Reset views and clear stale canvas content
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
// Clear all test canvases to prevent stale frames from previous sessions
modal.querySelectorAll('canvas').forEach(c => {
const ctx = c.getContext('2d');
if (ctx) ctx.clearRect(0, 0, c.width, c.height);
});
(document.getElementById('css-test-led-group') as HTMLElement).style.display = '';
// Input source selector: shown for both CSS test and CSPT test, hidden for api_input
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
const layersContainer = document.getElementById('css-test-layers') as HTMLElement | null;
if (layersContainer) layersContainer.innerHTML = '';
(document.getElementById('css-test-status') as HTMLElement).style.display = '';
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.connecting');
// Reset FPS tracking
_cssTestFpsActualHistory = [];
// For api_input: hide LED/FPS controls, show FPS chart
const ledControlGroup = document.getElementById('css-test-led-fps-group') as HTMLElement | null;
const fpsChartGroup = document.getElementById('css-test-fps-chart-group') as HTMLElement | null;
if (_cssTestIsApiInput) {
if (ledControlGroup) ledControlGroup.style.display = 'none';
if (fpsChartGroup) fpsChartGroup.style.display = '';
_cssTestStartFpsSampling();
// Use large LED count (buffer auto-sizes) and high poll FPS
_cssTestConnect(sourceId, 1000, 60);
} else {
if (ledControlGroup) ledControlGroup.style.display = '';
if (fpsChartGroup) fpsChartGroup.style.display = 'none';
// Restore LED count + FPS + Enter key handlers
const ledCount = _getCssTestLedCount();
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
ledInput!.value = ledCount as any;
ledInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
fpsInput!.value = fpsVal as any;
fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
_cssTestConnect(sourceId, ledCount, fpsVal);
}
}
function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
// Close existing connection if any
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
// Bump generation so any late messages from old WS are ignored
const gen = ++_cssTestGeneration;
if (!fps) fps = _getCssTestFps();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const apiKey = localStorage.getItem('wled_api_key') || '';
const isTransient = sourceId === '__preview__' && _cssTestTransientConfig;
let wsUrl;
if (isTransient) {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
} else if (_cssTestCSPTMode && _cssTestCSPTId) {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
} else {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
}
_cssTestWs = new WebSocket(wsUrl);
_cssTestWs.binaryType = 'arraybuffer';
if (isTransient) {
_cssTestWs.onopen = () => {
if (gen !== _cssTestGeneration) return;
_cssTestWs!.send(JSON.stringify(_cssTestTransientConfig));
};
}
_cssTestWs.onmessage = (event) => {
// Ignore messages from a stale connection
if (gen !== _cssTestGeneration) return;
if (typeof event.data === 'string') {
let msg: any;
try {
msg = JSON.parse(event.data);
} catch (e) {
console.error('CSS test WS parse error:', e);
return;
}
// Handle brightness updates for composite layers
if (msg.type === 'brightness') {
_cssTestUpdateBrightness(msg.values);
return;
}
// Handle frame dimensions — render border-width overlay
if (msg.type === 'frame_dims' && _cssTestMeta) {
_cssTestRenderBorderOverlay(msg.width, msg.height);
return;
}
// Initial metadata
_cssTestMeta = msg;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
_cssTestIsComposite = _cssTestMeta.layers && _cssTestMeta.layers.length > 0;
// Reset FPS timestamps so the initial bootstrap frame
// (sent right after metadata for api_input) isn't counted
if (_cssTestIsApiInput) _cssTestFpsTimestamps = [];
// Show correct view
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = (isPicture || _cssTestIsComposite) ? 'none' : '';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = isPicture ? '' : 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = _cssTestIsComposite ? '' : 'none';
(document.getElementById('css-test-status') as HTMLElement).style.display = 'none';
// Widen modal for picture sources to show the screen rectangle larger
const modalContent = document.querySelector('#test-css-source-modal .modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : '';
// Hide LED count control for picture sources (LED count is fixed by calibration)
(document.getElementById('css-test-led-group') as HTMLElement).style.display = isPicture ? 'none' : '';
// Show fire button for notification sources (direct only; composite has per-layer buttons)
const isNotify = _cssTestMeta.source_type === 'notification';
const layerInfos = _cssTestMeta.layer_infos || [];
_cssTestNotificationIds = isNotify
? [_cssTestSourceId]
: layerInfos.filter((li: any) => li.is_notification).map((li: any) => li.id);
const fireBtn = document.getElementById('css-test-fire-btn') as HTMLElement | null;
if (fireBtn) fireBtn.style.display = (isNotify && !_cssTestIsComposite) ? '' : 'none';
// Populate rect screen labels for picture sources
if (isPicture) {
const nameEl = document.getElementById('css-test-rect-name') as HTMLElement | null;
const ledsEl = document.getElementById('css-test-rect-leds') as HTMLElement | null;
if (nameEl) nameEl.textContent = _cssTestMeta.source_name || '';
if (ledsEl) ledsEl.textContent = `${_cssTestMeta.led_count} LEDs`;
// Render tick marks after layout settles
requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta.edges));
}
// Build composite layer canvases
if (_cssTestIsComposite) {
_cssTestBuildLayers(_cssTestMeta.layers, _cssTestMeta.source_type, _cssTestMeta.layer_infos);
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-layers-axis', _cssTestMeta.led_count));
}
// Render strip axis for non-picture, non-composite views
if (!isPicture && !_cssTestIsComposite) {
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-strip-axis', _cssTestMeta.led_count));
}
} else {
const raw = new Uint8Array(event.data);
// Check JPEG frame preview: [0xFD] [jpeg_bytes]
if (raw.length > 1 && raw[0] === 0xFD) {
const jpegBlob = new Blob([raw.subarray(1)], { type: 'image/jpeg' });
const url = URL.createObjectURL(jpegBlob);
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (screen) {
// Preload image to avoid flicker on swap
const img = new Image();
img.onload = () => {
const oldUrl = (screen as any)._blobUrl;
(screen as any)._blobUrl = url;
screen.style.backgroundImage = `url(${url})`;
screen.style.backgroundSize = 'cover';
screen.style.backgroundPosition = 'center';
if (oldUrl) URL.revokeObjectURL(oldUrl);
// Set aspect ratio from first decoded frame
const rect = document.getElementById('css-test-rect') as HTMLElement | null;
if (rect && !(rect as any)._aspectSet && img.naturalWidth && img.naturalHeight) {
(rect as any).style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
rect.style.height = 'auto';
(rect as any)._aspectSet = true;
requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta?.edges));
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
return;
}
// Check composite wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
if (raw.length > 3 && raw[0] === 0xFE && _cssTestIsComposite) {
const layerCount = raw[1];
const ledCount = (raw[2] << 8) | raw[3];
const rgbSize = ledCount * 3;
let offset = 4;
const layers: Uint8Array[] = [];
for (let i = 0; i < layerCount; i++) {
layers.push(raw.subarray(offset, offset + rgbSize));
offset += rgbSize;
}
const composite = raw.subarray(offset, offset + rgbSize);
_cssTestLayerData = { layerCount, ledCount, layers, composite };
_cssTestLatestRgb = composite;
} else {
// Standard format: raw RGB
_cssTestLatestRgb = raw;
}
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
_cssTestFpsTimestamps.push(performance.now());
}
}
};
_cssTestWs.onerror = () => {
if (gen !== _cssTestGeneration) return;
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error');
};
_cssTestWs.onclose = () => {
if (gen === _cssTestGeneration) _cssTestWs = null;
};
// Start render loop (only once)
if (!_cssTestRaf) _cssTestRenderLoop();
}
const _BELL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>';
function _cssTestBuildLayers(layerNames: any[], sourceType: any, layerInfos: any[]) {
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
// Composite result first, then individual layers
let html = `<div class="css-test-layer css-test-layer-composite">` +
`<canvas class="css-test-layer-canvas" data-layer-idx="composite"></canvas>` +
`<span class="css-test-layer-label">${sourceType === 'composite' ? t('color_strip.test.composite') : ''}</span>` +
`</div>`;
for (let i = 0; i < layerNames.length; i++) {
const info = layerInfos && layerInfos[i];
const isNotify = info && info.is_notification;
const hasBri = info && info.has_brightness;
const fireBtn = isNotify
? `<button class="css-test-fire-btn" onclick="event.stopPropagation(); fireCssTestNotificationLayer('${info.id}')" title="${t('color_strip.notification.test')}">${_BELL_SVG}</button>`
: '';
const briLabel = hasBri
? `<span class="css-test-layer-brightness" data-layer-idx="${i}" style="display:none"></span>`
: '';
let calLabel = '';
if (info && info.is_picture && info.calibration_led_count) {
const mismatch = _cssTestMeta.led_count !== info.calibration_led_count;
calLabel = `<span class="css-test-layer-cal${mismatch ? ' css-test-layer-cal-warn' : ''}" data-layer-idx="${i}">${mismatch ? ICON_WARNING + ' ' : ''}${_cssTestMeta.led_count}/${info.calibration_led_count}</span>`;
}
html += `<div class="css-test-layer css-test-strip-wrap">` +
`<canvas class="css-test-layer-canvas" data-layer-idx="${i}"></canvas>` +
`<span class="css-test-layer-label">${escapeHtml(layerNames[i])}</span>` +
calLabel +
briLabel +
fireBtn +
`</div>`;
}
container.innerHTML = html;
}
function _cssTestUpdateBrightness(values: any) {
if (!values) return;
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
for (let i = 0; i < values.length; i++) {
const el = container.querySelector(`.css-test-layer-brightness[data-layer-idx="${i}"]`) as HTMLElement | null;
if (!el) continue;
const v = values[i];
if (v != null) {
el.innerHTML = `${ICON_SUN_DIM} ${v}%`;
el.style.display = '';
} else {
el.style.display = 'none';
}
}
}
export function applyCssTestSettings() {
if (!_cssTestSourceId) return;
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
let leds = parseInt(ledInput?.value ?? '', 10);
if (isNaN(leds) || leds < 1) leds = 1;
if (leds > 2000) leds = 2000;
if (ledInput) ledInput.value = leds as any;
localStorage.setItem(_CSS_TEST_LED_KEY, String(leds));
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
let fps = parseInt(fpsInput?.value ?? '', 10);
if (isNaN(fps) || fps < 1) fps = 1;
if (fps > 60) fps = 60;
if (fpsInput) fpsInput.value = fps as any;
localStorage.setItem(_CSS_TEST_FPS_KEY, String(fps));
// Clear frame data but keep views/layout intact to avoid size jump
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
// Read selected input source from selector (both CSS and CSPT modes)
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
if (inputSel && inputSel.value) {
_cssTestSourceId = inputSel.value;
// Re-detect api_input when source changes
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === _cssTestSourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
}
// Reconnect (generation counter ignores stale frames from old WS)
_cssTestConnect(_cssTestSourceId, leds, fps);
}
function _cssTestRenderLoop() {
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
if (!_cssTestMeta) return;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
if (_cssTestIsComposite && _cssTestLayerData) {
_cssTestRenderLayers(_cssTestLayerData);
} else if (isPicture && _cssTestLatestRgb) {
_cssTestRenderRect(_cssTestLatestRgb, _cssTestMeta.edges);
} else if (_cssTestLatestRgb) {
_cssTestRenderStrip(_cssTestLatestRgb);
}
}
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ledCount = rgbBytes.length / 3;
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
const di = i * 4;
data[di] = rgbBytes[si];
data[di + 1] = rgbBytes[si + 1];
data[di + 2] = rgbBytes[si + 2];
data[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
function _cssTestRenderLayers(data: any) {
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
// Composite canvas is first
const compositeCanvas = container.querySelector('[data-layer-idx="composite"]') as HTMLCanvasElement | null;
if (compositeCanvas) _cssTestRenderStripCanvas(compositeCanvas, data.composite);
// Individual layer canvases
for (let i = 0; i < data.layers.length; i++) {
const canvas = container.querySelector(`[data-layer-idx="${i}"]`) as HTMLCanvasElement | null;
if (canvas) _cssTestRenderStripCanvas(canvas, data.layers[i]);
}
}
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return;
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
const di = i * 4;
data[di] = rgbBytes[si];
data[di + 1] = rgbBytes[si + 1];
data[di + 2] = rgbBytes[si + 2];
data[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
// edges: [{ edge: "top"|..., indices: [outputIdx, ...] }, ...]
// indices are pre-computed on server: reverse + offset already applied
const edgeMap: Record<string, number[]> = { top: [], right: [], bottom: [], left: [] };
for (const e of edges) {
if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
}
for (const [edge, indices] of Object.entries(edgeMap)) {
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
if (!canvas) continue;
const count = indices.length;
if (count === 0) { canvas.width = 0; continue; }
const isH = edge === 'top' || edge === 'bottom';
canvas.width = isH ? count : 1;
canvas.height = isH ? 1 : count;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(canvas.width, canvas.height);
const px = imageData.data;
for (let i = 0; i < count; i++) {
const si = indices[i] * 3;
const di = i * 4;
px[di] = rgbBytes[si] || 0;
px[di + 1] = rgbBytes[si + 1] || 0;
px[di + 2] = rgbBytes[si + 2] || 0;
px[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
}
function _cssTestRenderBorderOverlay(frameW: number, frameH: number) {
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (!screen || !_cssTestMeta) return;
// Remove any previous border overlay
screen.querySelectorAll('.css-test-border-overlay').forEach(el => el.remove());
const bw = _cssTestMeta.border_width;
if (!bw || bw <= 0) return;
const edges = _cssTestMeta.edges || [];
const activeEdges = new Set(edges.map((e: any) => e.edge));
// Compute border as percentage of frame dimensions
const bwPctH = (bw / frameH * 100).toFixed(2); // % for top/bottom
const bwPctW = (bw / frameW * 100).toFixed(2); // % for left/right
const overlayStyle = 'position:absolute;pointer-events:none;background:rgba(var(--primary-color-rgb, 76,175,80),0.18);border:1px solid rgba(var(--primary-color-rgb, 76,175,80),0.4);';
if (activeEdges.has('top')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;left:0;right:0;height:${bwPctH}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('bottom')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}bottom:0;left:0;right:0;height:${bwPctH}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('left')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;bottom:0;left:0;width:${bwPctW}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('right')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;bottom:0;right:0;width:${bwPctW}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
// Show border width label
const label = document.createElement('div');
label.className = 'css-test-border-overlay css-test-border-label';
label.textContent = `${bw}px`;
screen.appendChild(label);
}
function _cssTestRenderTicks(edges: any[]) {
const canvas = document.getElementById('css-test-rect-ticks') as HTMLCanvasElement | null;
const rectEl = document.getElementById('css-test-rect') as HTMLElement | null;
if (!canvas || !rectEl) return;
const outer = canvas.parentElement!;
const outerRect = outer.getBoundingClientRect();
const gridRect = rectEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = outerRect.width * dpr;
canvas.height = outerRect.height * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, outerRect.width, outerRect.height);
// Grid offset within outer container (the padding area)
const gx = gridRect.left - outerRect.left;
const gy = gridRect.top - outerRect.top;
const gw = gridRect.width;
const gh = gridRect.height;
const edgeThick = 14; // matches CSS grid-template
// Build edge map with indices
const edgeMap: Record<string, number[]> = { top: [], right: [], bottom: [], left: [] };
for (const e of edges) {
if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
}
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
ctx.strokeStyle = tickStroke;
ctx.fillStyle = tickFill;
ctx.lineWidth = 1;
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
const edgeGeom: Record<string, any> = {
top: { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy, dir: -1, horizontal: true },
bottom: { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy + gh, dir: 1, horizontal: true },
left: { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx, dir: -1, horizontal: false },
right: { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx + gw, dir: 1, horizontal: false },
};
for (const [edge, indices] of Object.entries(edgeMap)) {
const count = indices.length;
if (count === 0) continue;
const geo = edgeGeom[edge];
// Determine which ticks to label
const labelsToShow = new Set([0]);
if (count > 1) labelsToShow.add(count - 1);
if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
const maxDigits = String(Math.max(...indices)).length;
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 20;
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(count / s) <= 4) { step = s; break; }
}
const tickPx = (i: number) => (i / (count - 1)) * edgeLen;
const placed: number[] = [];
// Place boundary ticks first
labelsToShow.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < count - 1; i++) {
if (indices[i] % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
}
const tickLen = 6;
labelsToShow.forEach(idx => {
const label = String(indices[idx]);
const fraction = count > 1 ? idx / (count - 1) : 0.5;
if (geo.horizontal) {
const tx = geo.x1 + fraction * (geo.x2 - geo.x1);
const ty = geo.y;
const tickDir = geo.dir; // -1 for top (tick goes up), +1 for bottom (tick goes down)
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx, ty + tickDir * tickLen);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = tickDir < 0 ? 'bottom' : 'top';
ctx.fillText(label, tx, ty + tickDir * (tickLen + 2));
} else {
const ty = geo.y1 + fraction * (geo.y2 - geo.y1);
const tx = geo.x;
const tickDir = geo.dir; // -1 for left (tick goes left), +1 for right (tick goes right)
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx + tickDir * tickLen, ty);
ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = tickDir < 0 ? 'right' : 'left';
ctx.fillText(label, tx + tickDir * (tickLen + 2), ty);
}
});
}
}
function _cssTestRenderStripAxis(canvasId: string, ledCount: number) {
const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!canvas || ledCount <= 0) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
ctx.strokeStyle = tickStroke;
ctx.fillStyle = tickFill;
ctx.lineWidth = 1;
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textBaseline = 'top';
const tickLen = 5;
// Determine which ticks to label
const labelsToShow = new Set([0]);
if (ledCount > 1) labelsToShow.add(ledCount - 1);
if (ledCount > 2) {
const maxDigits = String(ledCount - 1).length;
const minSpacing = maxDigits * 7 + 8;
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(ledCount / s) <= Math.floor(w / minSpacing)) { step = s; break; }
}
const tickPx = (i: number) => (i / (ledCount - 1)) * w;
const placed: number[] = [];
labelsToShow.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < ledCount - 1; i++) {
if (i % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
}
labelsToShow.forEach(idx => {
const fraction = ledCount > 1 ? idx / (ledCount - 1) : 0.5;
const tx = fraction * w;
ctx.beginPath();
ctx.moveTo(tx, 0);
ctx.lineTo(tx, tickLen);
ctx.stroke();
// Align first tick left, last tick right, others center
if (idx === 0) ctx.textAlign = 'left';
else if (idx === ledCount - 1) ctx.textAlign = 'right';
else ctx.textAlign = 'center';
ctx.fillText(String(idx), tx, tickLen + 1);
});
}
export function fireCssTestNotification() {
for (const id of _cssTestNotificationIds) {
testNotification(id);
}
}
export function fireCssTestNotificationLayer(sourceId: string) {
testNotification(sourceId);
}
let _cssTestFpsSampleInterval: ReturnType<typeof setInterval> | null = null;
function _cssTestStartFpsSampling() {
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
// Sample FPS every 1 second
_cssTestFpsSampleInterval = setInterval(() => {
const now = performance.now();
// Count frames in the last 1 second
const cutoff = now - 1000;
_cssTestFpsTimestamps = _cssTestFpsTimestamps.filter(t => t >= cutoff);
const fps = _cssTestFpsTimestamps.length;
_cssTestFpsActualHistory.push(fps);
if (_cssTestFpsActualHistory.length > _CSS_TEST_FPS_MAX_SAMPLES)
_cssTestFpsActualHistory.shift();
// Update numeric display (match target card format)
const valueEl = document.getElementById('css-test-fps-value') as HTMLElement | null;
if (valueEl) valueEl.textContent = String(fps);
const avgEl = document.getElementById('css-test-fps-avg') as HTMLElement | null;
if (avgEl && _cssTestFpsActualHistory.length > 1) {
const avg = _cssTestFpsActualHistory.reduce((a, b) => a + b, 0) / _cssTestFpsActualHistory.length;
avgEl.textContent = `avg ${avg.toFixed(1)}`;
}
// Create or update chart
if (!_cssTestFpsChart) {
_cssTestFpsChart = createFpsSparkline(
'css-test-fps-chart',
_cssTestFpsActualHistory,
[], // no "current" dataset, just actual
60, // y-axis max
);
}
if (_cssTestFpsChart) {
const ds = _cssTestFpsChart.data.datasets[0].data;
ds.length = 0;
ds.push(..._cssTestFpsActualHistory);
while (_cssTestFpsChart.data.labels.length < ds.length) _cssTestFpsChart.data.labels.push('');
_cssTestFpsChart.data.labels.length = ds.length;
_cssTestFpsChart.update('none');
}
}, 1000);
}
function _cssTestStopFpsSampling() {
if (_cssTestFpsSampleInterval) { clearInterval(_cssTestFpsSampleInterval); _cssTestFpsSampleInterval = null; }
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
}
export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestSourceId = null;
_cssTestIsComposite = false;
_cssTestLayerData = null;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
// Revoke blob URL for frame preview
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (screen && (screen as any)._blobUrl) { URL.revokeObjectURL((screen as any)._blobUrl); (screen as any)._blobUrl = null; screen.style.backgroundImage = ''; }
// Reset aspect ratio for next open
const rect = document.getElementById('css-test-rect') as HTMLElement | null;
if (rect) { (rect as any).style.aspectRatio = ''; rect.style.height = ''; (rect as any)._aspectSet = false; }
// Reset modal width
const modalContent = document.querySelector('#test-css-source-modal .modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = '';
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (modal) { modal.style.display = 'none'; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -222,7 +222,7 @@ function _gradientRenderCanvas(): void {
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
if (canvas.width !== W) canvas.width = W;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const H = canvas.height;
const imgData = ctx.createImageData(W, H);

View File

@@ -12,7 +12,7 @@ import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation } from '../types.ts';
@@ -57,7 +57,7 @@ function _getInterpolatedUptime(targetId: string): number | null {
function _cacheUptimeElements(): void {
_uptimeElements = {};
for (const id of _lastRunningIds) {
const el = document.querySelector(`[data-uptime-text="${id}"]`);
const el = document.querySelector(`[data-uptime-text="${CSS.escape(id)}"]`);
if (el) _uptimeElements[id] = el;
}
}
@@ -126,7 +126,7 @@ async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
if (!canvas) continue;
const actualH = _fpsHistory[id] || [];
const currentH = _fpsCurrentHistory[id] || [];
const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30;
const fpsTarget = parseFloat(canvas.dataset.fpsTarget ?? '30') || 30;
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, actualH, currentH, fpsTarget);
}
@@ -137,9 +137,9 @@ function _cacheMetricsElements(runningIds: string[]): void {
_metricsElements.clear();
for (const id of runningIds) {
_metricsElements.set(id, {
fps: document.querySelector(`[data-fps-text="${id}"]`),
errors: document.querySelector(`[data-errors-text="${id}"]`),
row: document.querySelector(`[data-target-id="${id}"]`),
fps: document.querySelector(`[data-fps-text="${CSS.escape(id)}"]`),
errors: document.querySelector(`[data-errors-text="${CSS.escape(id)}"]`),
row: document.querySelector(`[data-target-id="${CSS.escape(id)}"]`),
});
}
}
@@ -181,7 +181,7 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
// Update text values (use cached refs, fallback to querySelector)
const cached = _metricsElements.get(target.id);
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${CSS.escape(target.id)}"]`);
if (fpsEl) {
const effFps = state.fps_effective;
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
@@ -192,13 +192,13 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
}
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${CSS.escape(target.id)}"]`);
if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); }
// Update health dot — prefer streaming reachability when processing
const isLed = target.target_type === 'led' || target.target_type === 'wled';
if (isLed) {
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
const row = cached?.row || document.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
if (row) {
const dot = row.querySelector('.health-dot');
if (dot) {
@@ -217,7 +217,7 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
function _updateAutomationsInPlace(automations: Automation[]): void {
for (const a of automations) {
const card = document.querySelector(`[data-automation-id="${a.id}"]`);
const card = document.querySelector(`[data-automation-id="${CSS.escape(a.id)}"]`);
if (!card) continue;
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
if (badge) {
@@ -243,7 +243,7 @@ function _updateAutomationsInPlace(automations: Automation[]): void {
function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
for (const c of syncClocks) {
const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`);
const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`);
if (!card) continue;
const speedEl = card.querySelector('.dashboard-clock-speed');
if (speedEl) speedEl.textContent = `${c.speed}x`;
@@ -292,7 +292,7 @@ function _renderPollIntervalSelect(): string {
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
let _pollDebounce: ReturnType<typeof setTimeout> | null = null;
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
@@ -307,7 +307,7 @@ export function changeDashboardPollInterval(value: string | number): void {
}
function _getCollapsedSections(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
catch { return {}; }
}
@@ -315,7 +315,7 @@ export function toggleDashboardSection(sectionKey: string): void {
const collapsed = _getCollapsedSections();
collapsed[sectionKey] = !collapsed[sectionKey];
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
const header = document.querySelector(`[data-dashboard-section="${sectionKey}"]`);
const header = document.querySelector(`[data-dashboard-section="${CSS.escape(sectionKey)}"]`);
if (!header) return;
const content = header.nextElementSibling;
const chevron = header.querySelector('.dashboard-section-chevron');
@@ -379,10 +379,10 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
loadScenePresets(),
@@ -403,7 +403,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// Build dynamic HTML (targets, automations)
let dynamicHtml = '';
let runningIds = [];
let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else {
@@ -518,9 +518,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
await initPerfCharts();
// Event delegation for scene preset cards (attached once, works across innerHTML refreshes)
initScenePresetDelegation(container);
} else {
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic.innerHTML !== dynamicHtml) {
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
dynamic.innerHTML = dynamicHtml;
}
}
@@ -743,7 +745,7 @@ export async function dashboardStopAll(): Promise<void> {
if (!confirmed) return;
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
@@ -804,7 +806,7 @@ function _isDashboardActive(): boolean {
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
}
let _eventDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let _eventDebounceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
function _debouncedDashboardReload(forceFullRender: boolean = false): void {
if (!_isDashboardActive()) return;
clearTimeout(_eventDebounceTimer);

View File

@@ -215,7 +215,7 @@ export async function turnOffDevice(deviceId: any) {
}
export async function pingDevice(deviceId: any) {
const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`) as HTMLElement | null;
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
if (btn) btn.classList.add('spinning');
try {
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
@@ -403,7 +403,7 @@ export async function showSettings(deviceId: any) {
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
// Set zone mode radio from device
const savedMode = device.zone_mode || 'combined';
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`) as HTMLInputElement | null;
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${CSS.escape(savedMode)}"]`) as HTMLInputElement | null;
if (modeRadio) modeRadio.checked = true;
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
// Re-snapshot after zones are loaded so dirty-check baseline includes them
@@ -536,7 +536,7 @@ export async function saveDeviceSettings() {
// Brightness
export function updateBrightnessLabel(deviceId: any, value: any) {
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}
@@ -569,13 +569,13 @@ export async function fetchDeviceBrightness(deviceId: any) {
if (!resp.ok) return;
const data = await resp.json();
updateDeviceBrightness(deviceId, data.brightness);
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLInputElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`) as HTMLElement | null;
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (wrap) wrap.classList.remove('brightness-loading');
} catch (err) {
// Silently fail — device may be offline
@@ -731,10 +731,10 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
}
function _applyZoneCounts(deviceId: any, zones: any, counts: any) {
const card = document.querySelector(`[data-device-id="${deviceId}"]`);
const card = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`);
if (!card) return;
for (const zoneName of zones) {
const badge = card.querySelector(`[data-zone-name="${zoneName}"]`);
const badge = card.querySelector(`[data-zone-name="${CSS.escape(zoneName)}"]`);
if (!badge) continue;
const ledCount = counts[zoneName.toLowerCase()];
if (ledCount != null) {

View File

@@ -24,7 +24,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox');
const lightbox = document.getElementById('display-picker-lightbox')!;
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
const titleEl = lightbox.querySelector('.display-picker-title');
@@ -41,7 +41,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
@@ -55,7 +55,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
}
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
try {
@@ -133,7 +133,7 @@ window._adbConnectFromPicker = async function () {
export function closeDisplayPicker(event?: Event): void {
if (event && event.target && (event.target as HTMLElement).closest('.display-picker-content')) return;
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.remove('active');
lightbox?.classList.remove('active');
set_displayPickerCallback(null);
_pickerEngineType = null;
}
@@ -150,7 +150,7 @@ export function selectDisplay(displayIndex: number): void {
}
export function renderDisplayPickerLayout(displays: any[], engineType: string | null = null): void {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
if (!displays || displays.length === 0) {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;

View File

@@ -269,7 +269,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
_clampElementInContainer(el, container);
}
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -279,7 +279,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = el.offsetWidth, eh = el.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -470,10 +470,10 @@ function _applyFilter(query?: string): void {
// Parse structured filters: type:device, tag:foo, running:true
let textPart = q;
const parsedKinds = new Set();
const parsedTags = [];
const parsedKinds = new Set<string>();
const parsedTags: string[] = [];
const tokens = q.split(/\s+/);
const plainTokens = [];
const plainTokens: string[] = [];
for (const tok of tokens) {
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
@@ -720,8 +720,8 @@ function _renderGraph(container: HTMLElement): void {
const nodeGroup = svgEl.querySelector('.graph-nodes') as SVGGElement;
const edgeGroup = svgEl.querySelector('.graph-edges') as SVGGElement;
renderEdges(edgeGroup, _edges);
renderNodes(nodeGroup, _nodeMap, {
renderEdges(edgeGroup, _edges!);
renderNodes(nodeGroup, _nodeMap!, {
onNodeClick: _onNodeClick,
onNodeDblClick: _onNodeDblClick,
onEditNode: _onEditNode,
@@ -732,14 +732,14 @@ function _renderGraph(container: HTMLElement): void {
onCloneNode: _onCloneNode,
onActivatePreset: _onActivatePreset,
});
markOrphans(nodeGroup, _nodeMap, _edges);
markOrphans(nodeGroup, _nodeMap!, _edges!);
// Animated flow dots for running nodes
const runningIds = new Set<string>();
for (const node of _nodeMap.values()) {
for (const node of _nodeMap!.values()) {
if (node.running) runningIds.add(node.id);
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
// Set bounds for view clamping, then fit
if (_bounds) _canvas.setBounds(_bounds);
@@ -889,6 +889,8 @@ function _renderGraph(container: HTMLElement): void {
}
});
// Remove previous keydown listener to prevent leaks on re-render
container.removeEventListener('keydown', _onKeydown);
container.addEventListener('keydown', _onKeydown);
container.setAttribute('tabindex', '0');
container.style.outline = 'none';
@@ -1039,8 +1041,9 @@ function _initLegendDrag(legendEl: Element | null): void {
/* ── Minimap (draggable header & resize handle) ── */
function _initMinimap(mmEl: HTMLElement | null): void {
if (!mmEl || !_nodeMap || !_bounds) return;
function _initMinimap(mmElArg: HTMLElement | null): void {
if (!mmElArg || !_nodeMap || !_bounds) return;
const mmEl: HTMLElement = mmElArg;
const svg = mmEl.querySelector('svg') as SVGSVGElement | null;
if (!svg) return;
const container = mmEl.closest('.graph-container') as HTMLElement;
@@ -1108,7 +1111,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
function _initResizeHandle(rh: HTMLElement | null, corner: string): void {
if (!rh) return;
let rs = null, rss = null;
let rs: { x: number; y: number } | null = null, rss: { w: number; h: number; left: number } | null = null;
rh.addEventListener('pointerdown', (e) => {
e.preventDefault(); e.stopPropagation();
rs = { x: e.clientX, y: e.clientY };
@@ -1116,7 +1119,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
rh.setPointerCapture(e.pointerId);
});
rh.addEventListener('pointermove', (e) => {
if (!rs) return;
if (!rs || !rss) return;
const cr = container.getBoundingClientRect();
const dy = e.clientY - rs.y;
const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
@@ -1274,7 +1277,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
const dock = saved?.dock || 'tl';
_applyToolbarDock(tbEl, container, dock, false);
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -1289,7 +1292,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
});
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -1410,7 +1413,7 @@ async function _bulkDeleteSelected(): Promise<void> {
);
if (!ok) return;
for (const id of _selectedIds) {
const node = _nodeMap.get(id);
const node = _nodeMap?.get(id);
if (node) _onDeleteNode(node);
}
_selectedIds.clear();
@@ -1506,10 +1509,12 @@ function _updateNodeRunning(nodeId: string, running: boolean): void {
// Update flow dots since running set changed
if (edgeGroup) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
if (_nodeMap) {
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
}
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1559,7 +1564,7 @@ function _onKeydown(e: KeyboardEvent): void {
_detachSelectedEdge();
} else if (_selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
const node = _nodeMap?.get(nodeId);
if (node) _onDeleteNode(node);
} else if (_selectedIds.size > 1) {
_bulkDeleteSelected();
@@ -1614,13 +1619,13 @@ function _navigateDirection(dir: string): void {
if (!_nodeMap || _nodeMap.size === 0) return;
// Get current anchor node
let anchor = null;
let anchor: any = null;
if (_selectedIds.size === 1) {
anchor = _nodeMap.get([..._selectedIds][0]);
}
if (!anchor) {
// Select first visible node (topmost-leftmost)
let best = null;
let best: any = null;
for (const n of _nodeMap.values()) {
if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
}
@@ -1638,7 +1643,7 @@ function _navigateDirection(dir: string): void {
const cx = anchor.x + anchor.width / 2;
const cy = anchor.y + anchor.height / 2;
let bestNode = null;
let bestNode: any = null;
let bestDist = Infinity;
for (const n of _nodeMap.values()) {
@@ -1702,8 +1707,8 @@ function _selectAll(): void {
/* ── Edge click ── */
function _onEdgeClick(edgePath: Element, nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void {
const fromId = edgePath.getAttribute('data-from');
const toId = edgePath.getAttribute('data-to');
const fromId = edgePath.getAttribute('data-from') ?? '';
const toId = edgePath.getAttribute('data-to') ?? '';
const field = edgePath.getAttribute('data-field') || '';
// Track selected edge for Delete key detach
@@ -1819,10 +1824,10 @@ function _onDragPointerMove(e: PointerEvent): void {
node.x = item.startX + gdx;
node.y = item.startY + gdy;
if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap!, _edges!);
_updateMinimapNode(item.id, node);
}
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges);
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null as any, _nodeMap!, _edges!);
} else {
const ds = _dragState as DragStateSingle;
const node = _nodeMap!.get(ds.nodeId);
@@ -1831,8 +1836,8 @@ function _onDragPointerMove(e: PointerEvent): void {
node.y = ds.startNode.y + gdy;
ds.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) {
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
}
_updateMinimapNode(ds.nodeId, node);
}
@@ -1867,7 +1872,7 @@ function _onDragPointerUp(): void {
if (edgeGroup && _edges && _nodeMap) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1886,7 +1891,7 @@ function _initRubberBand(svgEl: SVGSVGElement): void {
e.preventDefault();
_rubberBand = {
startGraph: _canvas.screenToGraph(e.clientX, e.clientY),
startGraph: _canvas!.screenToGraph(e.clientX, e.clientY),
startClient: { x: e.clientX, y: e.clientY },
active: false,
};
@@ -1930,10 +1935,10 @@ function _onRubberBandUp(): void {
const rect = document.querySelector('.graph-selection-rect') as SVGElement | null;
if (_rubberBand.active && rect && _nodeMap) {
const rx = parseFloat(rect.getAttribute('x'));
const ry = parseFloat(rect.getAttribute('y'));
const rw = parseFloat(rect.getAttribute('width'));
const rh = parseFloat(rect.getAttribute('height'));
const rx = parseFloat(rect.getAttribute('x') ?? '0');
const ry = parseFloat(rect.getAttribute('y') ?? '0');
const rw = parseFloat(rect.getAttribute('width') ?? '0');
const rh = parseFloat(rect.getAttribute('height') ?? '0');
_selectedIds.clear();
for (const node of _nodeMap.values()) {
@@ -2014,9 +2019,9 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
e.stopPropagation();
e.preventDefault();
const sourceNodeId = port.getAttribute('data-node-id');
const sourceKind = port.getAttribute('data-node-kind');
const portType = port.getAttribute('data-port-type');
const sourceNodeId = port.getAttribute('data-node-id') ?? '';
const sourceKind = port.getAttribute('data-node-kind') ?? '';
const portType = port.getAttribute('data-port-type') ?? '';
const sourceNode = _nodeMap?.get(sourceNodeId);
if (!sourceNode) return;
@@ -2029,7 +2034,7 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
const dragPath = document.createElementNS(SVG_NS, 'path');
dragPath.setAttribute('class', 'graph-drag-edge');
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
const root = svgEl.querySelector('.graph-root');
const root = svgEl.querySelector('.graph-root')!;
root.appendChild(dragPath);
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
@@ -2108,9 +2113,9 @@ function _onConnectPointerUp(e: PointerEvent): void {
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
const targetNodeId = targetPort.getAttribute('data-node-id');
const targetKind = targetPort.getAttribute('data-node-kind');
const targetPortType = targetPort.getAttribute('data-port-type');
const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
@@ -2143,8 +2148,8 @@ async function _doConnect(targetId: string, targetKind: string, field: string, s
/* ── Undo / Redo ── */
const _undoStack = [];
const _redoStack = [];
const _undoStack: UndoAction[] = [];
const _redoStack: UndoAction[] = [];
const _MAX_UNDO = 30;
/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
@@ -2167,7 +2172,7 @@ export async function graphRedo(): Promise<void> { await _redo(); }
async function _undo(): Promise<void> {
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
const action = _undoStack.pop();
const action = _undoStack.pop()!;
try {
await action.undo();
_redoStack.push(action);
@@ -2182,7 +2187,7 @@ async function _undo(): Promise<void> {
async function _redo(): Promise<void> {
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
const action = _redoStack.pop();
const action = _redoStack.pop()!;
try {
await action.redo();
_undoStack.push(action);
@@ -2201,7 +2206,7 @@ let _helpVisible = false;
function _loadHelpPos(): AnchoredRect | null {
try {
const saved = JSON.parse(localStorage.getItem('graph_help_pos'));
const saved = JSON.parse(localStorage.getItem('graph_help_pos')!);
return saved || { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 };
} catch { return { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; }
}
@@ -2265,7 +2270,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
const field = edgePath.getAttribute('data-field') || '';
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to');
const toId = edgePath.getAttribute('data-to') ?? '';
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
@@ -2289,7 +2294,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
});
menu.appendChild(btn);
container.querySelector('.graph-container').appendChild(menu);
container.querySelector('.graph-container')!.appendChild(menu);
_edgeContextMenu = menu;
}
@@ -2318,11 +2323,11 @@ async function _detachSelectedEdge(): Promise<void> {
let _hoverTooltip: HTMLDivElement | null = null; // the <div> element, created once per graph render
let _hoverTooltipChart: any = null; // Chart.js instance
let _hoverTimer: ReturnType<typeof setTimeout> | null = null; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | null = null; // 1s polling interval
let _hoverTimer: ReturnType<typeof setTimeout> | undefined = undefined; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | undefined = undefined; // 1s polling interval
let _hoverNodeId: string | null = null; // currently shown node id
let _hoverFpsHistory = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory = []; // rolling fps_current samples
let _hoverFpsHistory: number[] = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory: number[] = []; // rolling fps_current samples
const HOVER_DELAY_MS = 300;
const HOVER_HISTORY_LEN = 20;
@@ -2374,7 +2379,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
if (related && nodeEl.contains(related)) return;
clearTimeout(_hoverTimer);
_hoverTimer = null;
_hoverTimer = undefined;
const nodeId = nodeEl.getAttribute('data-id');
if (nodeId === _hoverNodeId) {
@@ -2384,7 +2389,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
}
function _positionTooltip(_nodeEl: Element | null, container: HTMLElement): void {
if (!_canvas || !_hoverTooltip) return;
if (!_canvas || !_hoverTooltip || !_hoverNodeId) return;
const node = _nodeMap?.get(_hoverNodeId);
if (!node) return;
@@ -2467,7 +2472,7 @@ async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTML
function _hideNodeTooltip(): void {
clearInterval(_hoverPollInterval);
_hoverPollInterval = null;
_hoverPollInterval = undefined;
_hoverNodeId = null;
if (_hoverTooltipChart) {
@@ -2478,7 +2483,7 @@ function _hideNodeTooltip(): void {
_hoverTooltip.classList.remove('gnt-fade-in');
_hoverTooltip.classList.add('gnt-fade-out');
_hoverTooltip.addEventListener('animationend', () => {
if (_hoverTooltip.classList.contains('gnt-fade-out')) {
if (_hoverTooltip?.classList.contains('gnt-fade-out')) {
_hoverTooltip.style.display = 'none';
}
}, { once: true });
@@ -2506,6 +2511,7 @@ async function _fetchTooltipMetrics(nodeId: string, container: HTMLElement, node
const uptimeSec = metrics.uptime_seconds ?? 0;
// Update text rows
if (!_hoverTooltip) return;
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');

View File

@@ -141,7 +141,7 @@ function _ensureBrightnessEntitySelect() {
}
export function patchKCTargetMetrics(target: any) {
const card = document.querySelector(`[data-kc-target-id="${target.id}"]`);
const card = document.querySelector(`[data-kc-target-id="${CSS.escape(target.id)}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
@@ -523,8 +523,8 @@ export async function showKCEditor(targetId: any = null, cloneData: any = null)
try {
// Load sources, pattern templates, and value sources in parallel
const [sources, patTemplates, valueSources] = await Promise.all([
streamsCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch(),
]);
@@ -751,7 +751,7 @@ export async function deleteKCTarget(targetId: any) {
// ===== KC BRIGHTNESS =====
export function updateKCBrightnessLabel(targetId: any, value: any) {
const slider = document.querySelector(`[data-kc-brightness="${targetId}"]`) as HTMLElement;
const slider = document.querySelector(`[data-kc-brightness="${CSS.escape(targetId)}"]`) as HTMLElement;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}

View File

@@ -87,7 +87,7 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
try {
// Load sources for background capture
const sources = await streamsCache.fetch().catch(() => []);
const sources = await streamsCache.fetch().catch((): any[] => []);
const bgSelect = document.getElementById('pattern-bg-source') as HTMLSelectElement;
bgSelect.innerHTML = '';
@@ -116,7 +116,7 @@ export async function showPatternTemplateEditor(templateId: string | null = null
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null);
let _editorTags = [];
let _editorTags: string[] = [];
if (templateId) {
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
@@ -340,7 +340,7 @@ export function removePatternRect(index: number): void {
export function renderPatternCanvas(): void {
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const w = canvas.width;
const h = canvas.height;
@@ -396,8 +396,8 @@ export function renderPatternCanvas(): void {
ctx.strokeRect(rx, ry, rw, rh);
// Edge highlight
let edgeDir = null;
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
let edgeDir: string | null = null;
if (isDragging && patternCanvasDragMode?.startsWith('resize-')) {
edgeDir = patternCanvasDragMode.replace('resize-', '');
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
edgeDir = patternEditorHoverHit;
@@ -586,16 +586,16 @@ function _patternCanvasDragMove(e: MouseEvent | { clientX: number; clientY: numb
const mx = (e.clientX - canvasRect.left) * scaleX;
const my = (e.clientY - canvasRect.top) * scaleY;
const dx = (mx - patternCanvasDragStart.mx) / w;
const dy = (my - patternCanvasDragStart.my) / h;
const orig = patternCanvasDragOrigRect;
const dx = (mx - patternCanvasDragStart!.mx!) / w;
const dy = (my - patternCanvasDragStart!.my!) / h;
const orig = patternCanvasDragOrigRect!;
const r = patternEditorRects[patternEditorSelectedIdx];
if (patternCanvasDragMode === 'move') {
r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
} else if (patternCanvasDragMode.startsWith('resize-')) {
const dir = patternCanvasDragMode.replace('resize-', '');
} else if (patternCanvasDragMode?.startsWith('resize-')) {
const dir = patternCanvasDragMode!.replace('resize-', '');
let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
if (dir.includes('w')) { nx = orig.x + dx; nw = orig.width - dx; }
if (dir.includes('e')) { nw = orig.width + dx; }
@@ -631,7 +631,7 @@ function _patternCanvasDragEnd(e: MouseEvent): void {
const my = (e.clientY - canvasRect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
let newHoverHit: string | null = null;
if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
@@ -717,8 +717,8 @@ function _patternCanvasMouseDown(e: MouseEvent | { offsetX?: number; offsetY?: n
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
// Check delete button on hovered or selected rects first
for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) {
@@ -739,7 +739,7 @@ function _patternCanvasMouseDown(e: MouseEvent | { offsetX?: number; offsetY?: n
// Test all rects; selected rect takes priority so it stays interactive
// even when overlapping with others.
const selIdx = patternEditorSelectedIdx;
const testOrder = [];
const testOrder: number[] = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) testOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) testOrder.push(i);
@@ -795,15 +795,15 @@ function _patternCanvasMouseMove(e: MouseEvent | { offsetX?: number; offsetY?: n
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
let newHoverHit: string | null = null;
// Selected rect takes priority for hover so edges stay reachable under overlaps
const selIdx = patternEditorSelectedIdx;
const hoverOrder = [];
const hoverOrder: number[] = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) hoverOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) hoverOrder.push(i);

View File

@@ -55,21 +55,21 @@ export function renderPerfSection(): string {
return `<div class="perf-charts-grid">
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-cpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div>
</div>
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-ram-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
</div>
<div class="perf-chart-card" id="perf-gpu-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-gpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>

View File

@@ -15,10 +15,11 @@ import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../c
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts';
import type { ScenePreset } from '../types.ts';
let _editingId: string | null = null;
let _allTargets = []; // fetched on capture open
let _allTargets: any[] = []; // fetched on capture open
let _sceneTagsInput: TagInput | null = null;
class ScenePresetEditorModal extends Modal {
@@ -76,7 +77,7 @@ export function createSceneCard(preset: ScenePreset) {
const colorStyle = cardColorStyle(preset.id);
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">&#x2715;</button>
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">&#x2715;</button>
</div>
<div class="card-header">
<div class="card-title" title="${escapeHtml(preset.name)}">${escapeHtml(preset.name)}</div>
@@ -88,10 +89,10 @@ export function createSceneCard(preset: ScenePreset) {
</div>
${renderTagChips(preset.tags)}
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="cloneScenePreset('${preset.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
<button class="btn btn-icon btn-secondary" data-action="clone-scene" data-id="${preset.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit-scene" data-id="${preset.id}" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" data-action="recapture-scene" data-id="${preset.id}" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
${cardColorButton(preset.id, 'data-scene-id')}
</div>
</div>`;
@@ -106,7 +107,7 @@ export async function loadScenePresets(): Promise<ScenePreset[]> {
export function renderScenePresetsSection(presets: ScenePreset[]): string | { headerExtra: string; content: string } {
if (!presets || presets.length === 0) return '';
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" data-action="capture-scene" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
@@ -120,7 +121,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
].filter(Boolean).join(' \u00b7 ');
const pStyle = cardColorStyle(preset.id);
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}"${pStyle ? ` style="${pStyle}"` : ''}>
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" data-action="navigate-scene" data-id="${preset.id}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_SCENE}</span>
<div>
@@ -130,7 +131,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
<button class="dashboard-action-btn start" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
</div>
</div>`;
}
@@ -155,7 +156,7 @@ export async function openScenePresetCapture(): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
_refreshTargetSelect();
} catch { /* ignore */ }
}
@@ -190,7 +191,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
// Pre-add targets already in the preset
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
@@ -200,7 +201,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -294,7 +295,7 @@ function _addTargetToList(targetId: string, targetName: string): void {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
@@ -320,10 +321,7 @@ export async function addSceneTarget(): Promise<void> {
if (tgt) _addTargetToList(tgt.id, tgt.name);
}
export function removeSceneTarget(btn: HTMLElement): void {
btn.closest('.scene-target-item').remove();
_refreshTargetSelect();
}
// removeSceneTarget is now handled via event delegation on the modal
// ===== Activate =====
@@ -403,7 +401,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
// Pre-add targets from the cloned preset
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
@@ -413,7 +411,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -456,6 +454,57 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
}
}
// ===== Event delegation for scene preset card actions =====
const _sceneCardActions: Record<string, (id: string) => void> = {
'delete-scene': deleteScenePreset,
'clone-scene': cloneScenePreset,
'edit-scene': editScenePreset,
'recapture-scene': recaptureScenePreset,
'activate-scene': activateScenePreset,
};
export function initScenePresetDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action) return;
if (action === 'capture-scene') {
e.stopPropagation();
openScenePresetCapture();
return;
}
if (action === 'navigate-scene') {
// Only navigate if click wasn't on a child button
if ((e.target as HTMLElement).closest('button')) return;
navigateToCard('automations', null, 'scenes', 'data-scene-id', id);
return;
}
if (action === 'remove-scene-target') {
const item = btn.closest('.scene-target-item');
if (item) {
item.remove();
_refreshTargetSelect();
}
return;
}
if (!id) return;
const handler = _sceneCardActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ===== Helpers =====
function _reloadScenesTab(): void {
@@ -466,3 +515,18 @@ function _reloadScenesTab(): void {
// Also refresh dashboard (scene presets section)
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
}
// ===== Modal event delegation (for target list remove buttons) =====
const _sceneEditorModal = document.getElementById('scene-preset-editor-modal');
if (_sceneEditorModal) {
_sceneEditorModal.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action="remove-scene-target"]');
if (!btn) return;
const item = btn.closest('.scene-target-item');
if (item) {
item.remove();
_refreshTargetSelect();
}
});
}

View File

@@ -0,0 +1,548 @@
/**
* Streams — Audio template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_cachedAudioTemplates,
audioTemplatesCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, setupBackdropClose } from '../core/ui.ts';
import {
getAudioEngineIcon,
ICON_AUDIO_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── TagInput instance for audio template modal ──
let _audioTemplateTagsInput: TagInput | null = null;
class AudioTemplateModal extends Modal {
constructor() { super('audio-template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('audio-template-name') as HTMLInputElement).value,
description: (document.getElementById('audio-template-description') as HTMLInputElement).value,
engine: (document.getElementById('audio-template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
setCurrentEditingAudioTemplateId(null);
set_audioTemplateNameManuallyEdited(false);
}
}
const audioTemplateModal = new AudioTemplateModal();
// ===== Audio Templates =====
async function loadAvailableAudioEngines() {
try {
const response = await fetchWithAuth('/audio-engines');
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
const data = await response.json();
setAvailableAudioEngines(data.engines || []);
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableAudioEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${engine.type.toUpperCase()}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('audio_template.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableAudioEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableAudioEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getAudioEngineIcon(e.type), label: e.type.toUpperCase(), desc: '' }));
if (_audioEngineIconSelect) { _audioEngineIconSelect.updateItems(items); }
else { _audioEngineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_audioEngineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading audio engines:', error);
showToast(t('audio_template.error.engines') + ': ' + error.message, 'error');
}
}
let _audioEngineIconSelect: IconSelect | null = null;
export async function onAudioEngineChange() {
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (_audioEngineIconSelect) _audioEngineIconSelect.setValue(engineType);
const configSection = document.getElementById('audio-engine-config-section')!;
const configFields = document.getElementById('audio-engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableAudioEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_audioTemplateNameManuallyEdited && !(document.getElementById('audio-template-id') as HTMLInputElement).value) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = engine.type.toUpperCase();
}
const hint = document.getElementById('audio-engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('audio_template.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
gridHtml += `
<label class="config-grid-label" for="audio-config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="audio-config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : `
<input type="${fieldType}" id="audio-config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
}
configSection.style.display = 'block';
}
function populateAudioEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`audio-config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectAudioEngineConfig() {
const config: any = {};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadAudioTemplates() {
try {
await audioTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading audio templates:', error);
showToast(t('audio_template.error.load'), 'error');
}
}
export async function showAddAudioTemplateModal(cloneData: any = null) {
setCurrentEditingAudioTemplateId(null);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.add')}`;
(document.getElementById('audio-template-form') as HTMLFormElement).reset();
(document.getElementById('audio-template-id') as HTMLInputElement).value = '';
document.getElementById('audio-engine-config-section')!.style.display = 'none';
document.getElementById('audio-template-error')!.style.display = 'none';
set_audioTemplateNameManuallyEdited(!!cloneData);
(document.getElementById('audio-template-name') as HTMLInputElement).oninput = () => { set_audioTemplateNameManuallyEdited(true); };
await loadAvailableAudioEngines();
if (cloneData) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('audio-template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(cloneData.engine_config);
}
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
}
export async function editAudioTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
const template = await response.json();
setCurrentEditingAudioTemplateId(templateId);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
(document.getElementById('audio-template-id') as HTMLInputElement).value = templateId;
(document.getElementById('audio-template-name') as HTMLInputElement).value = template.name;
(document.getElementById('audio-template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableAudioEngines();
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = template.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(template.engine_config);
document.getElementById('audio-template-error')!.style.display = 'none';
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(template.tags || []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
} catch (error: any) {
console.error('Error loading audio template:', error);
showToast(t('audio_template.error.load') + ': ' + error.message, 'error');
}
}
export async function closeAudioTemplateModal() {
await audioTemplateModal.close();
}
export async function saveAudioTemplate() {
const templateId = currentEditingAudioTemplateId;
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('audio_template.error.required'), 'error');
return;
}
const description = (document.getElementById('audio-template-description') as HTMLInputElement).value.trim();
const engineConfig = collectAudioEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save audio template');
}
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
audioTemplateModal.forceClose();
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error saving audio template:', error);
document.getElementById('audio-template-error')!.textContent = (error as any).message;
document.getElementById('audio-template-error')!.style.display = 'block';
}
}
export async function deleteAudioTemplate(templateId: any) {
const confirmed = await showConfirm(t('audio_template.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete audio template');
}
showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error deleting audio template:', error);
showToast(t('audio_template.error.delete') + ': ' + error.message, 'error');
}
}
export async function cloneAudioTemplate(templateId: any) {
try {
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load audio template');
const tmpl = await resp.json();
showAddAudioTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone audio template:', error);
showToast(t('audio_template.error.clone_failed'), 'error');
}
}
// ===== Audio Template Test =====
const NUM_BANDS_TPL = 64;
const TPL_PEAK_DECAY = 0.02;
const TPL_BEAT_FLASH_DECAY = 0.06;
let _tplTestWs: WebSocket | null = null;
let _tplTestAnimFrame: number | null = null;
let _tplTestLatest: any = null;
let _tplTestPeaks = new Float32Array(NUM_BANDS_TPL);
let _tplTestBeatFlash = 0;
let _currentTestAudioTemplateId: string | null = null;
const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop: true, lock: true });
export async function showTestAudioTemplateModal(templateId: any) {
_currentTestAudioTemplateId = templateId;
// Find template's engine type so we show the correct device list
const template = _cachedAudioTemplates.find((t: any) => t.id === templateId);
const engineType = template ? template.engine_type : null;
// Load audio devices for picker — filter by engine type
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
try {
const resp = await fetchWithAuth('/audio-devices');
if (resp.ok) {
const data = await resp.json();
// Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType]
: (data.devices || []);
deviceSelect.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
}
} catch {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
// Restore last used device
const lastDevice = localStorage.getItem('lastAudioTestDevice');
if (lastDevice) {
const opt = Array.from(deviceSelect.options).find((o: any) => o.value === lastDevice);
if (opt) deviceSelect.value = lastDevice;
}
// Reset visual state
document.getElementById('audio-template-test-canvas')!.style.display = 'none';
document.getElementById('audio-template-test-stats')!.style.display = 'none';
document.getElementById('audio-template-test-status')!.style.display = 'none';
document.getElementById('test-audio-template-start-btn')!.style.display = '';
_tplCleanupTest();
testAudioTemplateModal.open();
}
export function closeTestAudioTemplateModal() {
_tplCleanupTest();
testAudioTemplateModal.forceClose();
_currentTestAudioTemplateId = null;
}
export function startAudioTemplateTest() {
if (!_currentTestAudioTemplateId) return;
const deviceVal = (document.getElementById('test-audio-template-device') as HTMLSelectElement).value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
localStorage.setItem('lastAudioTestDevice', deviceVal);
// Show canvas + stats, hide run button, disable device picker
document.getElementById('audio-template-test-canvas')!.style.display = '';
document.getElementById('audio-template-test-stats')!.style.display = '';
document.getElementById('test-audio-template-start-btn')!.style.display = 'none';
(document.getElementById('test-audio-template-device') as HTMLSelectElement).disabled = true;
const statusEl = document.getElementById('audio-template-test-status')!;
statusEl.textContent = t('audio_source.test.connecting');
statusEl.style.display = '';
// Reset state
_tplTestLatest = null;
_tplTestPeaks.fill(0);
_tplTestBeatFlash = 0;
document.getElementById('audio-template-test-rms')!.textContent = '---';
document.getElementById('audio-template-test-peak')!.textContent = '---';
document.getElementById('audio-template-test-beat-dot')!.classList.remove('active');
// Size canvas
const canvas = document.getElementById('audio-template-test-canvas') as HTMLCanvasElement;
_tplSizeCanvas(canvas);
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-templates/${_currentTestAudioTemplateId}/test/ws?token=${encodeURIComponent(apiKey)}&device_index=${devIdx}&is_loopback=${devLoop === '1' ? '1' : '0'}`;
try {
_tplTestWs = new WebSocket(wsUrl);
_tplTestWs.onopen = () => {
statusEl.style.display = 'none';
};
_tplTestWs.onmessage = (event) => {
try { _tplTestLatest = JSON.parse(event.data); } catch {}
};
_tplTestWs.onclose = () => { _tplTestWs = null; };
_tplTestWs.onerror = () => {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
};
} catch {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
return;
}
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
function _tplCleanupTest() {
if (_tplTestAnimFrame) {
cancelAnimationFrame(_tplTestAnimFrame);
_tplTestAnimFrame = null;
}
if (_tplTestWs) {
_tplTestWs.onclose = null;
_tplTestWs.close();
_tplTestWs = null;
}
_tplTestLatest = null;
// Re-enable device picker
const devSel = document.getElementById('test-audio-template-device') as HTMLSelectElement | null;
if (devSel) devSel.disabled = false;
}
function _tplSizeCanvas(canvas: HTMLCanvasElement) {
const rect = canvas.parentElement!.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _tplRenderLoop() {
_tplRenderSpectrum();
if (testAudioTemplateModal.isOpen && _tplTestWs) {
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
}
function _tplRenderSpectrum() {
const canvas = document.getElementById('audio-template-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = _tplTestLatest;
if (!data || !data.spectrum) return;
const spectrum = data.spectrum;
const gap = 1;
const barWidth = (w - gap * (NUM_BANDS_TPL - 1)) / NUM_BANDS_TPL;
// Beat flash
if (data.beat) _tplTestBeatFlash = Math.min(1.0, data.beat_intensity + 0.3);
if (_tplTestBeatFlash > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${_tplTestBeatFlash * 0.08})`;
ctx.fillRect(0, 0, w, h);
_tplTestBeatFlash = Math.max(0, _tplTestBeatFlash - TPL_BEAT_FLASH_DECAY);
}
for (let i = 0; i < NUM_BANDS_TPL; i++) {
const val = Math.min(1, spectrum[i]);
const barHeight = val * h;
const x = i * (barWidth + gap);
const y = h - barHeight;
const hue = (1 - val) * 120;
ctx.fillStyle = `hsl(${hue}, 85%, 50%)`;
ctx.fillRect(x, y, barWidth, barHeight);
if (val > _tplTestPeaks[i]) {
_tplTestPeaks[i] = val;
} else {
_tplTestPeaks[i] = Math.max(0, _tplTestPeaks[i] - TPL_PEAK_DECAY);
}
const peakY = h - _tplTestPeaks[i] * h;
const peakHue = (1 - _tplTestPeaks[i]) * 120;
ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`;
ctx.fillRect(x, peakY, barWidth, 2);
}
document.getElementById('audio-template-test-rms')!.textContent = (data.rms * 100).toFixed(1) + '%';
document.getElementById('audio-template-test-peak')!.textContent = (data.peak * 100).toFixed(1) + '%';
const beatDot = document.getElementById('audio-template-test-beat-dot')!;
if (data.beat) {
beatDot.classList.add('active');
} else {
beatDot.classList.remove('active');
}
}

View File

@@ -0,0 +1,585 @@
/**
* Streams — Capture template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableEngines, setAvailableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_cachedStreams, _cachedDisplays,
captureTemplatesCache, displaysCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
import { openDisplayPicker, formatDisplayLabel } from './displays.ts';
import {
getEngineIcon,
ICON_CAPTURE_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── TagInput instance for capture template modal ──
let _captureTemplateTagsInput: TagInput | null = null;
class CaptureTemplateModal extends Modal {
constructor() { super('template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('template-name') as HTMLInputElement).value,
description: (document.getElementById('template-description') as HTMLInputElement).value,
engine: (document.getElementById('template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('[data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
// ===== Capture Templates =====
async function loadCaptureTemplates() {
try {
await captureTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading capture templates:', error);
showToast(t('streams.error.load'), 'error');
}
}
export async function showAddTemplateModal(cloneData: any = null) {
setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`;
(document.getElementById('template-form') as HTMLFormElement).reset();
(document.getElementById('template-id') as HTMLInputElement).value = '';
document.getElementById('engine-config-section')!.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
set_templateNameManuallyEdited(!!cloneData);
(document.getElementById('template-name') as HTMLInputElement).oninput = () => { set_templateNameManuallyEdited(true); };
await loadAvailableEngines();
// Pre-fill from clone data after engines are loaded
if (cloneData) {
(document.getElementById('template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onEngineChange();
populateEngineConfig(cloneData.engine_config);
}
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
templateModal.open();
templateModal.snapshot();
}
export async function editTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const template = await response.json();
setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
(document.getElementById('template-id') as HTMLInputElement).value = templateId;
(document.getElementById('template-name') as HTMLInputElement).value = template.name;
(document.getElementById('template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableEngines();
(document.getElementById('template-engine') as HTMLSelectElement).value = template.engine_type;
await onEngineChange();
populateEngineConfig(template.engine_config);
await loadDisplaysForTest();
const testResults = document.getElementById('template-test-results');
if (testResults) testResults.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(template.tags || []);
templateModal.open();
templateModal.snapshot();
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
export async function closeTemplateModal() {
await templateModal.close();
}
export function updateCaptureDuration(value: any) {
document.getElementById('test-template-duration-value')!.textContent = value;
localStorage.setItem('capture_duration', value);
}
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration') as HTMLInputElement;
const durationValue = document.getElementById('test-template-duration-value')!;
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
export async function showTestTemplateModal(templateId: any) {
try {
const templates = await captureTemplatesCache.fetch();
const template = templates.find(tp => tp.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
setCurrentTestingTemplate(template);
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
setupBackdropClose((testTemplateModal as any).el, () => closeTestTemplateModal());
} catch (error) {
if (error.isAuth) return;
showToast(t('templates.error.load'), 'error');
}
}
export function closeTestTemplateModal() {
testTemplateModal.forceClose();
setCurrentTestingTemplate(null);
}
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
const data = await response.json();
setAvailableEngines(data.engines || []);
const select = document.getElementById('template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = engine.name;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) }));
if (_engineIconSelect) { _engineIconSelect.updateItems(items); }
else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_engineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
let _engineIconSelect: IconSelect | null = null;
export async function onEngineChange() {
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (_engineIconSelect) _engineIconSelect.setValue(engineType);
const configSection = document.getElementById('engine-config-section')!;
const configFields = document.getElementById('engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_templateNameManuallyEdited && !(document.getElementById('template-id') as HTMLInputElement).value) {
(document.getElementById('template-name') as HTMLInputElement).value = engine.name || engineType;
}
const hint = document.getElementById('engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
// Known select options for specific config keys
const CONFIG_SELECT_OPTIONS = {
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
};
// IconSelect definitions for specific config keys
const CONFIG_ICON_SELECT = {
camera_backend: {
columns: 2,
items: [
{ value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') },
{ value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') },
{ value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') },
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
],
},
};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const selectOptions = CONFIG_SELECT_OPTIONS[key];
gridHtml += `
<label class="config-grid-label" for="config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : selectOptions ? `
<select id="config-${key}" data-config-key="${key}">
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
// Apply IconSelect to known config selects
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
const sel = document.getElementById(`config-${key}`);
if (sel) new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
}
}
configSection.style.display = 'block';
}
function populateEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectEngineConfig() {
const config: any = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadDisplaysForTest() {
try {
// Use engine-specific display list for engines with own devices (camera, scrcpy)
const engineType = currentTestingTemplate?.engine_type;
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
const url = engineHasOwnDisplays
? `/config/displays?engine_type=${engineType}`
: '/config/displays';
// Always refetch for engines with own displays (devices may change); use cache for desktop
if (!_cachedDisplays || engineHasOwnDisplays) {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
displaysCache.update(displaysData.displays || []);
}
let selectedIndex: number | null = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null && _cachedDisplays) {
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
if (found) selectedIndex = found.index;
}
if (selectedIndex === null && _cachedDisplays) {
const primary = _cachedDisplays.find(d => d.is_primary);
if (primary) selectedIndex = primary.index;
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
}
if (selectedIndex !== null && _cachedDisplays) {
const display = _cachedDisplays.find(d => d.index === selectedIndex);
(window as any).onTestDisplaySelected(selectedIndex, display);
}
} catch (error) {
console.error('Error loading displays:', error);
}
}
export function runTemplateTest() {
if (!currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = (document.getElementById('test-template-display') as HTMLSelectElement).value;
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = currentTestingTemplate;
localStorage.setItem('lastTestDisplayIndex', displayIndex);
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
_runTestViaWS(
'/capture-templates/test/ws',
{},
{
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration,
preview_width: previewWidth,
},
captureDuration,
);
}
function buildTestStatsHtml(result: any) {
// Support both REST format (nested) and WS format (flat)
const p = result.performance || result;
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
const frameCount = p.frame_count ?? 0;
const fps = p.actual_fps ?? p.fps ?? 0;
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
const w = result.full_capture?.width ?? result.width ?? 0;
const h = result.full_capture?.height ?? result.height ?? 0;
const res = `${w}x${h}`;
let html = `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
if (frameCount > 1) {
html += `
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
return html;
}
// ===== Shared WebSocket test helper =====
/**
* Run a capture test via WebSocket, streaming intermediate previews into
* the overlay spinner and opening the lightbox with the final result.
*
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
* @param {number} duration Test duration for overlay progress ring
*/
export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessage: any = null, duration = 5) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
const params = new URLSearchParams({ token: apiKey, preview_width: String(previewWidth), ...queryParams });
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
showOverlaySpinner(t('streams.test.running'), duration);
let gotResult = false;
let ws;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
return;
}
// Close WS when user cancels overlay
const patchCloseBtn = () => {
const closeBtn = document.querySelector('.overlay-spinner-close') as HTMLElement | null;
if (closeBtn) {
const origHandler = closeBtn.onclick;
closeBtn.onclick = () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
if (origHandler) (origHandler as any)();
};
}
};
patchCloseBtn();
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
const origAbort = window._overlayAbortController;
if (origAbort) {
origAbort.signal.addEventListener('abort', () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
}, { once: true });
}
ws.onopen = () => {
if (firstMessage) {
ws.send(JSON.stringify(firstMessage));
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'frame') {
updateOverlayPreview(msg.thumbnail, msg);
} else if (msg.type === 'result') {
gotResult = true;
hideOverlaySpinner();
(window as any).openLightbox(msg.full_image, buildTestStatsHtml(msg));
ws.close();
} else if (msg.type === 'error') {
hideOverlaySpinner();
showToast(msg.detail || 'Test failed', 'error');
ws.close();
}
} catch (e) {
console.error('Error parsing test WS message:', e);
}
};
ws.onerror = () => {
if (!gotResult) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed'), 'error');
}
};
ws.onclose = () => {
if (!gotResult) {
hideOverlaySpinner();
}
};
}
export async function saveTemplate() {
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const description = (document.getElementById('template-description') as HTMLInputElement).value.trim();
const engineConfig = collectEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose();
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error')!.textContent = (error as any).message;
document.getElementById('template-error')!.style.display = 'block';
}
}
export async function deleteTemplate(templateId: any) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -208,9 +208,7 @@ function _formatElapsed(seconds: number): string {
export function createSyncClockCard(clock: SyncClock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = clock.is_running
? `pauseSyncClock('${clock.id}')`
: `resumeSyncClock('${clock.id}')`;
const toggleAction = clock.is_running ? 'pause' : 'resume';
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
@@ -232,14 +230,46 @@ export function createSyncClockCard(clock: SyncClock) {
${renderTagChips(clock.tags)}
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); resetSyncClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneSyncClock('${clock.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editSyncClock('${clock.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
<button class="btn btn-icon btn-secondary" data-action="${toggleAction}" data-id="${clock.id}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" data-action="reset" data-id="${clock.id}" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" data-action="clone" data-id="${clock.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" data-id="${clock.id}" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Expose to global scope for inline onclick handlers ──
// ── Event delegation for sync-clock card actions ──
const _syncClockActions: Record<string, (id: string) => void> = {
pause: pauseSyncClock,
resume: resumeSyncClock,
reset: resetSyncClock,
clone: cloneSyncClock,
edit: editSyncClock,
};
export function initSyncClockDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
// Only handle actions within a sync-clock card (data-id on card root)
const card = btn.closest<HTMLElement>('[data-id]');
const section = btn.closest<HTMLElement>('[data-card-section="sync-clocks"]');
if (!card || !section) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action || !id) return;
const handler = _syncClockActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ── Expose to global scope for HTML template onclick handlers & graph-editor ──
window.showSyncClockModal = showSyncClockModal;
window.closeSyncClockModal = closeSyncClockModal;

View File

@@ -115,9 +115,9 @@ document.addEventListener('languageChanged', () => {
// --- FPS sparkline history and chart instances for target cards ---
const _TARGET_MAX_FPS_SAMPLES = 30;
const _targetFpsHistory = {}; // fps_actual (rolling avg)
const _targetFpsCurrentHistory = {}; // fps_current (sends/sec)
const _targetFpsCharts = {};
const _targetFpsHistory: Record<string, number[]> = {}; // fps_actual (rolling avg)
const _targetFpsCurrentHistory: Record<string, number[]> = {}; // fps_current (sends/sec)
const _targetFpsCharts: Record<string, any> = {};
function _pushTargetFps(targetId: any, actual: any, current: any) {
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
@@ -154,7 +154,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
}
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null;
class TargetEditorModal extends Modal {
@@ -343,12 +343,12 @@ function _ensureProtocolIconSelect() {
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
}
export async function showTargetEditor(targetId = null, cloneData = null) {
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
try {
// Load devices, CSS sources, and value sources for dropdowns
const [devices, cssSources] = await Promise.all([
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch(),
]);
@@ -368,7 +368,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
deviceSelect.appendChild(opt);
});
let _editorTags = [];
let _editorTags: string[] = [];
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
@@ -598,14 +598,14 @@ export async function loadTargetsTab() {
try {
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []),
audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
outputTargetsCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
streamsCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
syncClocksCache.fetch().catch((): any[] => []),
]);
const colorStripSourceMap = {};
@@ -698,7 +698,7 @@ export async function loadTargetsTab() {
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null;
let changedTargetIds: Set<string> | null = null;
if (csDevices.isMounted()) {
// ── Incremental update: reconcile cards in-place ──
@@ -760,13 +760,13 @@ export async function loadTargetsTab() {
if ((device.capabilities || []).includes('brightness_control')) {
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`) as HTMLInputElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(device.id)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = String(bri);
slider.title = Math.round(bri / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(device.id)}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} else {
fetchDeviceBrightness(device.id);
@@ -780,7 +780,7 @@ export async function loadTargetsTab() {
// Patch "Last seen" labels in-place (avoids full card re-render on relative time changes)
for (const device of devicesWithState) {
const el = container.querySelector(`[data-last-seen="${device.id}"]`) as HTMLElement | null;
const el = container.querySelector(`[data-last-seen="${CSS.escape(device.id)}"]`) as HTMLElement | null;
if (el) {
const ts = device.state?.device_last_checked;
const label = ts ? formatRelativeTime(ts) : null;
@@ -918,7 +918,7 @@ function _buildLedTimingHTML(state: any) {
function _patchTargetMetrics(target: any) {
const container = document.getElementById('targets-panel-content');
if (!container) return;
const card = container.querySelector(`[data-target-id="${target.id}"]`);
const card = container.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
@@ -1141,7 +1141,7 @@ export async function stopAllKCTargets() {
async function _stopAllByType(targetType: any) {
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
@@ -1339,7 +1339,7 @@ function _renderLedStrip(canvas: any, rgbBytes: any) {
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
@@ -1447,7 +1447,7 @@ function connectLedPreviewWS(targetId: any) {
}
function _setPreviewButtonState(targetId: any, active: boolean) {
const btn = document.querySelector(`[data-led-preview-btn="${targetId}"]`);
const btn = document.querySelector(`[data-led-preview-btn="${CSS.escape(targetId)}"]`);
if (btn) {
btn.classList.toggle('btn-warning', active);
btn.classList.toggle('btn-secondary', !active);

View File

@@ -353,7 +353,7 @@ function showTutorialStep(index: number, direction: number = 1): void {
if (needsScroll) {
// Hide tooltip while scrolling to prevent stale position flash
const tt = overlay.querySelector('.tutorial-tooltip');
const tt = overlay.querySelector('.tutorial-tooltip') as HTMLElement | null;
if (tt) tt.style.visibility = 'hidden';
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
_waitForScrollEnd().then(() => {

View File

@@ -152,7 +152,7 @@ function _drawWaveformPreview(waveformType: any) {
canvas.height = cssH * dpr;
canvas.style.height = cssH + 'px';
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
@@ -552,7 +552,7 @@ const VS_HISTORY_SIZE = 200;
let _testVsWs: WebSocket | null = null;
let _testVsAnimFrame: number | null = null;
let _testVsLatest: any = null;
let _testVsHistory = [];
let _testVsHistory: number[] = [];
let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -Infinity;
@@ -602,7 +602,10 @@ export function testValueSource(sourceId: any) {
}
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
} catch {}
} catch (e) {
console.error('Value source test WS parse error:', e);
return;
}
};
_testVsWs.onclose = () => {
@@ -647,7 +650,7 @@ function _sizeVsCanvas(canvas: HTMLCanvasElement) {
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d').scale(dpr, dpr);
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _renderVsTestLoop() {
@@ -661,7 +664,7 @@ function _renderVsChart() {
const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
@@ -885,7 +888,7 @@ export function addSchedulePoint(time: string = '', value: number = 1.0) {
function _getScheduleFromUI() {
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
const schedule = [];
const schedule: { time: string; value: number }[] = [];
rows.forEach(row => {
const time = (row.querySelector('.schedule-time') as HTMLInputElement).value;
const value = parseFloat((row.querySelector('.schedule-value') as HTMLInputElement).value);

View File

@@ -202,11 +202,9 @@ interface Window {
saveScenePreset: (...args: any[]) => any;
closeScenePresetEditor: (...args: any[]) => any;
activateScenePreset: (...args: any[]) => any;
recaptureScenePreset: (...args: any[]) => any;
cloneScenePreset: (...args: any[]) => any;
deleteScenePreset: (...args: any[]) => any;
addSceneTarget: (...args: any[]) => any;
removeSceneTarget: (...args: any[]) => any;
// ─── Device Discovery ───
onDeviceTypeChanged: (...args: any[]) => any;

View File

@@ -12,19 +12,8 @@ const CACHE_NAME = 'ledgrab-v33';
// Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
const PRECACHE_URLS = [
'/static/css/base.css',
'/static/css/layout.css',
'/static/css/components.css',
'/static/css/cards.css',
'/static/css/modal.css',
'/static/css/calibration.css',
'/static/css/advanced-calibration.css',
'/static/css/dashboard.css',
'/static/css/streams.css',
'/static/css/patterns.css',
'/static/css/automations.css',
'/static/css/tutorials.css',
'/static/css/mobile.css',
'/static/dist/app.bundle.css',
'/static/dist/app.bundle.js',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
];

View File

@@ -1,5 +1,6 @@
"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores."""
import asyncio
import json
import threading
from pathlib import Path
@@ -106,6 +107,23 @@ class BaseJsonStore(Generic[T]):
logger.error(f"Failed to save {self._json_key} to {self.file_path}: {e}")
raise
async def _save_async(self) -> None:
"""Async wrapper around ``_save()`` — runs file I/O in a thread.
Use from ``async def`` route handlers to avoid blocking the event loop.
Caller must hold ``self._lock`` (same contract as ``_save``).
"""
await asyncio.to_thread(self._save)
async def async_delete(self, item_id: str) -> None:
"""Async version of ``delete()`` — offloads file I/O to a thread."""
with self._lock:
if item_id not in self._items:
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
del self._items[item_id]
await self._save_async()
logger.info(f"Deleted {self._entity_name}: {item_id}")
# ── Common CRUD ────────────────────────────────────────────────
def get_all(self) -> List[T]:

View File

@@ -177,10 +177,17 @@ class Device:
# 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')
}
_UPDATABLE_FIELDS: frozenset[str] = frozenset({
"name", "url", "led_count", "enabled", "device_type",
"baud_rate", "software_brightness", "auto_shutdown",
"send_latency_ms", "rgbw", "zone_mode", "tags",
"dmx_protocol", "dmx_start_universe", "dmx_start_channel",
"espnow_peer_mac", "espnow_channel",
"hue_username", "hue_client_key", "hue_entertainment_group_id",
"spi_speed_hz", "spi_led_type",
"chroma_device_type", "gamesense_device_type",
"default_css_processing_template_id",
})
class DeviceStore(BaseJsonStore[Device]):
@@ -235,43 +242,46 @@ class DeviceStore(BaseJsonStore[Device]):
gamesense_device_type: str = "keyboard",
) -> Device:
"""Create a new device."""
device_id = f"device_{uuid.uuid4().hex[:8]}"
with self._lock:
self._check_name_unique(name)
# Mock devices use their device ID as the URL authority
if device_type == "mock":
url = f"mock://{device_id}"
device_id = f"device_{uuid.uuid4().hex[:8]}"
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,
)
# Mock devices use their device ID as the URL authority
if device_type == "mock":
url = f"mock://{device_id}"
self._items[device_id] = device
self._save()
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,
)
logger.info(f"Created device {device_id}: {name}")
return device
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.
@@ -279,17 +289,37 @@ class DeviceStore(BaseJsonStore[Device]):
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
with self._lock:
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)
# Collect updates (ignore None values and unknown fields)
updates = {
key: value
for key, value in kwargs.items()
if value is not None and key in _UPDATABLE_FIELDS
}
device.updated_at = datetime.now(timezone.utc)
self._save()
# Check name uniqueness if name is being changed
new_name = updates.get("name")
if new_name is not None and new_name != device.name:
self._check_name_unique(new_name, exclude_id=device_id)
logger.info(f"Updated device {device_id}")
return device
# Build new Device from existing fields + updates (immutable pattern)
device_fields = device.to_dict()
# Map 'id' back to 'device_id' for the constructor
device_fields["device_id"] = device_fields.pop("id")
# Restore datetime objects (to_dict serializes them as ISO strings)
device_fields["created_at"] = device.created_at
device_fields["updated_at"] = datetime.now(timezone.utc)
# Apply updates
device_fields.update(updates)
new_device = Device(**device_fields)
self._items[device_id] = new_device
self._save()
logger.info(f"Updated device {device_id}")
return new_device
# ── Unique helpers ───────────────────────────────────────────