refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s
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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
395
server/src/wled_controller/api/routes/backup.py
Normal file
395
server/src/wled_controller/api/routes/backup.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
341
server/src/wled_controller/api/routes/output_targets_control.py
Normal file
341
server/src/wled_controller/api/routes/output_targets_control.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
377
server/src/wled_controller/api/routes/system_settings.py
Normal file
377
server/src/wled_controller/api/routes/system_settings.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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'")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
144
server/src/wled_controller/core/processing/auto_restart.py
Normal file
144
server/src/wled_controller/core/processing/auto_restart.py
Normal 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),
|
||||
})
|
||||
145
server/src/wled_controller/core/processing/device_health.py
Normal file
145
server/src/wled_controller/core/processing/device_health.py
Normal 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)
|
||||
189
server/src/wled_controller/core/processing/device_test_mode.py
Normal file
189
server/src/wled_controller/core/processing/device_test_mode.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
23
server/src/wled_controller/static/js/core/dom-utils.ts
Normal file
23
server/src/wled_controller/static/js/core/dom-utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') ?? ''));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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')}">⠇</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})">✕</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();
|
||||
}
|
||||
@@ -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})">✕</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">📋</button></div>
|
||||
`;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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) + '%';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}">✕</button>
|
||||
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">✕</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">✕</button>`;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">✕</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">✕</button>`;
|
||||
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">✕</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">✕</button>`;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">✕</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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user