Rework API input CSS: segments, remove led_count, HAOS light, test preview
API Input CSS rework:
- Remove led_count field from ApiInputColorStripSource (always auto-sizes)
- Add segment-based payload: solid, per_pixel, gradient modes
- Segments applied in order (last wins on overlap), auto-grow buffer
- Backward compatible: legacy {"colors": [...]} still works
- Pydantic validation: mode-specific field requirements
Test preview:
- Enable test preview button on api_input cards
- Hide LED/FPS controls for api_input (sender controls those)
- Show input source selector for all CSS tests (preselected)
- FPS sparkline chart using shared createFpsSparkline (same as target cards)
- Server only sends frames when push_generation changes (no idle frames)
HAOS integration:
- New light.py: ApiInputLight entity per api_input source (RGB + brightness)
- turn_on pushes solid segment, turn_off pushes fallback color
- Register wled_screen_controller.set_leds service for arbitrary segments
- New services.yaml with field definitions
- Coordinator: push_colors() and push_segments() methods
- Platform.LIGHT added to platforms list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
Platform.BUTTON,
|
Platform.BUTTON,
|
||||||
|
Platform.LIGHT,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
@@ -148,6 +151,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
coordinator.async_add_listener(_on_coordinator_update)
|
coordinator.async_add_listener(_on_coordinator_update)
|
||||||
|
|
||||||
|
# Register set_leds service (once across all entries)
|
||||||
|
async def handle_set_leds(call) -> None:
|
||||||
|
"""Handle the set_leds service call."""
|
||||||
|
source_id = call.data["source_id"]
|
||||||
|
segments = call.data["segments"]
|
||||||
|
for entry_data in hass.data[DOMAIN].values():
|
||||||
|
coord = entry_data.get(DATA_COORDINATOR)
|
||||||
|
if coord:
|
||||||
|
await coord.push_segments(source_id, segments)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not hass.services.has_service(DOMAIN, "set_leds"):
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"set_leds",
|
||||||
|
handle_set_leds,
|
||||||
|
schema=vol.Schema({
|
||||||
|
vol.Required("source_id"): str,
|
||||||
|
vol.Required("segments"): list,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -336,6 +336,38 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
|||||||
_LOGGER.warning("Failed to fetch scene presets: %s", err)
|
_LOGGER.warning("Failed to fetch scene presets: %s", err)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
|
||||||
|
"""Push flat color array to an api_input CSS source."""
|
||||||
|
async with self.session.post(
|
||||||
|
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||||
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
|
json={"colors": colors},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
|
) as resp:
|
||||||
|
if resp.status not in (200, 204):
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to push colors to source %s: %s %s",
|
||||||
|
source_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
|
||||||
|
"""Push segment data to an api_input CSS source."""
|
||||||
|
async with self.session.post(
|
||||||
|
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||||
|
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||||
|
json={"segments": segments},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||||
|
) as resp:
|
||||||
|
if resp.status not in (200, 204):
|
||||||
|
body = await resp.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to push segments to source %s: %s %s",
|
||||||
|
source_id, resp.status, body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
async def activate_scene(self, preset_id: str) -> None:
|
async def activate_scene(self, preset_id: str) -> None:
|
||||||
"""Activate a scene preset."""
|
"""Activate a scene preset."""
|
||||||
async with self.session.post(
|
async with self.session.post(
|
||||||
|
|||||||
140
custom_components/wled_screen_controller/light.py
Normal file
140
custom_components/wled_screen_controller/light.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Light platform for LED Screen Controller (api_input CSS sources)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.light import (
|
||||||
|
ATTR_BRIGHTNESS,
|
||||||
|
ATTR_RGB_COLOR,
|
||||||
|
ColorMode,
|
||||||
|
LightEntity,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN, DATA_COORDINATOR
|
||||||
|
from .coordinator import WLEDScreenControllerCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up LED Screen Controller api_input lights."""
|
||||||
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
if coordinator.data:
|
||||||
|
for source in coordinator.data.get("css_sources", []):
|
||||||
|
if source.get("source_type") == "api_input":
|
||||||
|
entities.append(
|
||||||
|
ApiInputLight(coordinator, source, entry.entry_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiInputLight(CoordinatorEntity, LightEntity):
|
||||||
|
"""Representation of an api_input CSS source as a light entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_color_mode = ColorMode.RGB
|
||||||
|
_attr_supported_color_modes = {ColorMode.RGB}
|
||||||
|
_attr_translation_key = "api_input_light"
|
||||||
|
_attr_icon = "mdi:led-strip-variant"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: WLEDScreenControllerCoordinator,
|
||||||
|
source: dict[str, Any],
|
||||||
|
entry_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the light."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._source_id: str = source["id"]
|
||||||
|
self._source_name: str = source.get("name", self._source_id)
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self._attr_unique_id = f"{self._source_id}_light"
|
||||||
|
|
||||||
|
# Local state — not derived from coordinator data
|
||||||
|
self._is_on: bool = False
|
||||||
|
self._rgb_color: tuple[int, int, int] = (255, 255, 255)
|
||||||
|
self._brightness: int = 255
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> dict[str, Any]:
|
||||||
|
"""Return device information — one virtual device per api_input source."""
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, self._source_id)},
|
||||||
|
"name": self._source_name,
|
||||||
|
"manufacturer": "WLED Screen Controller",
|
||||||
|
"model": "API Input CSS Source",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the entity name."""
|
||||||
|
return self._source_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return true if the light is on."""
|
||||||
|
return self._is_on
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rgb_color(self) -> tuple[int, int, int]:
|
||||||
|
"""Return the current RGB color."""
|
||||||
|
return self._rgb_color
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int:
|
||||||
|
"""Return the current brightness (0-255)."""
|
||||||
|
return self._brightness
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn on the light, optionally setting color and brightness."""
|
||||||
|
if ATTR_RGB_COLOR in kwargs:
|
||||||
|
self._rgb_color = kwargs[ATTR_RGB_COLOR]
|
||||||
|
if ATTR_BRIGHTNESS in kwargs:
|
||||||
|
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||||
|
|
||||||
|
# Scale RGB by brightness
|
||||||
|
scale = self._brightness / 255
|
||||||
|
r, g, b = self._rgb_color
|
||||||
|
scaled = [round(r * scale), round(g * scale), round(b * scale)]
|
||||||
|
|
||||||
|
await self.coordinator.push_segments(
|
||||||
|
self._source_id,
|
||||||
|
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
|
||||||
|
)
|
||||||
|
self._is_on = True
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn off the light by pushing the fallback color."""
|
||||||
|
fallback_color = self._get_fallback_color()
|
||||||
|
await self.coordinator.push_segments(
|
||||||
|
self._source_id,
|
||||||
|
[{"start": 0, "length": 9999, "mode": "solid", "color": fallback_color}],
|
||||||
|
)
|
||||||
|
self._is_on = False
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def _get_fallback_color(self) -> list[int]:
|
||||||
|
"""Read fallback_color from the source config in coordinator data."""
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return [0, 0, 0]
|
||||||
|
for source in self.coordinator.data.get("css_sources", []):
|
||||||
|
if source.get("id") == self._source_id:
|
||||||
|
fallback = source.get("fallback_color")
|
||||||
|
if fallback and len(fallback) >= 3:
|
||||||
|
return list(fallback[:3])
|
||||||
|
break
|
||||||
|
return [0, 0, 0]
|
||||||
19
custom_components/wled_screen_controller/services.yaml
Normal file
19
custom_components/wled_screen_controller/services.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
set_leds:
|
||||||
|
name: Set LEDs
|
||||||
|
description: Push segment data to an api_input color strip source
|
||||||
|
fields:
|
||||||
|
source_id:
|
||||||
|
name: Source ID
|
||||||
|
description: The api_input CSS source ID (e.g., css_abc12345)
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
segments:
|
||||||
|
name: Segments
|
||||||
|
description: >
|
||||||
|
List of segment objects. Each segment has: start (int), length (int),
|
||||||
|
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
|
||||||
|
colors ([[R,G,B],...] for per_pixel/gradient)
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
@@ -31,6 +31,11 @@
|
|||||||
"name": "{scene_name}"
|
"name": "{scene_name}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"light": {
|
||||||
|
"api_input_light": {
|
||||||
|
"name": "Light"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"processing": {
|
"processing": {
|
||||||
"name": "Processing"
|
"name": "Processing"
|
||||||
@@ -66,5 +71,21 @@
|
|||||||
"name": "Brightness Source"
|
"name": "Brightness Source"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"set_leds": {
|
||||||
|
"name": "Set LEDs",
|
||||||
|
"description": "Push segment data to an api_input color strip source.",
|
||||||
|
"fields": {
|
||||||
|
"source_id": {
|
||||||
|
"name": "Source ID",
|
||||||
|
"description": "The api_input CSS source ID (e.g., css_abc12345)."
|
||||||
|
},
|
||||||
|
"segments": {
|
||||||
|
"name": "Segments",
|
||||||
|
"description": "List of segment objects with start, length, mode, and color/colors fields."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,7 +410,8 @@ async def push_colors(
|
|||||||
):
|
):
|
||||||
"""Push raw LED colors to an api_input color strip source.
|
"""Push raw LED colors to an api_input color strip source.
|
||||||
|
|
||||||
The colors are forwarded to all running stream instances for this source.
|
Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
|
||||||
|
The payload is forwarded to all running stream instances for this source.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
source = store.get_source(source_id)
|
source = store.get_source(source_id)
|
||||||
@@ -420,15 +421,27 @@ async def push_colors(
|
|||||||
if not isinstance(source, ApiInputColorStripSource):
|
if not isinstance(source, ApiInputColorStripSource):
|
||||||
raise HTTPException(status_code=400, detail="Source is not an api_input type")
|
raise HTTPException(status_code=400, detail="Source is not an api_input type")
|
||||||
|
|
||||||
|
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||||
|
|
||||||
|
if body.segments is not None:
|
||||||
|
# Segment-based path
|
||||||
|
seg_dicts = [s.model_dump() for s in body.segments]
|
||||||
|
for stream in streams:
|
||||||
|
if hasattr(stream, "push_segments"):
|
||||||
|
stream.push_segments(seg_dicts)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"streams_updated": len(streams),
|
||||||
|
"segments_applied": len(body.segments),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Legacy flat colors path
|
||||||
colors_array = np.array(body.colors, dtype=np.uint8)
|
colors_array = np.array(body.colors, dtype=np.uint8)
|
||||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||||
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
|
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
|
||||||
|
|
||||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
|
||||||
for stream in streams:
|
for stream in streams:
|
||||||
if hasattr(stream, "push_colors"):
|
if hasattr(stream, "push_colors"):
|
||||||
stream.push_colors(colors_array)
|
stream.push_colors(colors_array)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"streams_updated": len(streams),
|
"streams_updated": len(streams),
|
||||||
@@ -708,18 +721,41 @@ async def css_api_input_ws(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if "text" in message:
|
if "text" in message:
|
||||||
# JSON frame: {"colors": [[R,G,B], ...]}
|
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
|
||||||
import json
|
import json
|
||||||
try:
|
try:
|
||||||
data = json.loads(message["text"])
|
data = json.loads(message["text"])
|
||||||
raw_colors = data.get("colors", [])
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
await websocket.send_json({"error": str(e)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "segments" in data:
|
||||||
|
# Segment-based path — validate and push
|
||||||
|
try:
|
||||||
|
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
|
||||||
|
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_json({"error": f"Invalid segment: {e}"})
|
||||||
|
continue
|
||||||
|
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||||
|
for stream in streams:
|
||||||
|
if hasattr(stream, "push_segments"):
|
||||||
|
stream.push_segments(seg_dicts)
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif "colors" in data:
|
||||||
|
try:
|
||||||
|
raw_colors = data["colors"]
|
||||||
colors_array = np.array(raw_colors, dtype=np.uint8)
|
colors_array = np.array(raw_colors, dtype=np.uint8)
|
||||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||||
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
||||||
continue
|
continue
|
||||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
await websocket.send_json({"error": str(e)})
|
await websocket.send_json({"error": str(e)})
|
||||||
continue
|
continue
|
||||||
|
else:
|
||||||
|
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
|
||||||
|
continue
|
||||||
|
|
||||||
elif "bytes" in message:
|
elif "bytes" in message:
|
||||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||||
@@ -732,7 +768,7 @@ async def css_api_input_ws(
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Push to all running streams
|
# Push to all running streams (colors_array path only reaches here)
|
||||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||||
for stream in streams:
|
for stream in streams:
|
||||||
if hasattr(stream, "push_colors"):
|
if hasattr(stream, "push_colors"):
|
||||||
@@ -799,6 +835,10 @@ async def test_color_strip_ws(
|
|||||||
try:
|
try:
|
||||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||||
|
|
||||||
|
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||||
|
is_api_input = isinstance(stream, ApiInputColorStripStream)
|
||||||
|
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
|
||||||
|
|
||||||
# Send metadata as first message
|
# Send metadata as first message
|
||||||
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
|
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
|
||||||
is_composite = isinstance(source, CompositeColorStripSource)
|
is_composite = isinstance(source, CompositeColorStripSource)
|
||||||
@@ -874,6 +914,15 @@ async def test_color_strip_ws(
|
|||||||
await websocket.send_bytes(b''.join(parts))
|
await websocket.send_bytes(b''.join(parts))
|
||||||
elif composite_colors is not None:
|
elif composite_colors is not None:
|
||||||
await websocket.send_bytes(composite_colors.tobytes())
|
await websocket.send_bytes(composite_colors.tobytes())
|
||||||
|
else:
|
||||||
|
# For api_input: only send when new data was pushed
|
||||||
|
if is_api_input:
|
||||||
|
gen = stream.push_generation
|
||||||
|
if gen != _last_push_gen:
|
||||||
|
_last_push_gen = gen
|
||||||
|
colors = stream.get_latest_colors()
|
||||||
|
if colors is not None:
|
||||||
|
await websocket.send_bytes(colors.tobytes())
|
||||||
else:
|
else:
|
||||||
colors = stream.get_latest_colors()
|
colors = stream.get_latest_colors()
|
||||||
if colors is not None:
|
if colors is not None:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Literal, Optional
|
from typing import Dict, List, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
from wled_controller.api.schemas.devices import Calibration
|
from wled_controller.api.schemas.devices import Calibration
|
||||||
|
|
||||||
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
|
|||||||
count: int = Field(description="Number of sources")
|
count: int = Field(description="Number of sources")
|
||||||
|
|
||||||
|
|
||||||
class ColorPushRequest(BaseModel):
|
class SegmentPayload(BaseModel):
|
||||||
"""Request to push raw LED colors to an api_input source."""
|
"""A single segment for segment-based LED color updates."""
|
||||||
|
|
||||||
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)")
|
start: int = Field(ge=0, description="Starting LED index")
|
||||||
|
length: int = Field(ge=1, description="Number of LEDs in segment")
|
||||||
|
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
|
||||||
|
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
|
||||||
|
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_mode_fields(self) -> "SegmentPayload":
|
||||||
|
if self.mode == "solid":
|
||||||
|
if self.color is None or len(self.color) != 3:
|
||||||
|
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
|
||||||
|
if not all(0 <= c <= 255 for c in self.color):
|
||||||
|
raise ValueError("solid color values must be 0-255")
|
||||||
|
elif self.mode == "per_pixel":
|
||||||
|
if not self.colors:
|
||||||
|
raise ValueError("per_pixel mode requires non-empty 'colors' list")
|
||||||
|
for c in self.colors:
|
||||||
|
if len(c) != 3:
|
||||||
|
raise ValueError("each color in per_pixel must be [R,G,B]")
|
||||||
|
elif self.mode == "gradient":
|
||||||
|
if not self.colors or len(self.colors) < 2:
|
||||||
|
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
|
||||||
|
for c in self.colors:
|
||||||
|
if len(c) != 3:
|
||||||
|
raise ValueError("each color stop in gradient must be [R,G,B]")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ColorPushRequest(BaseModel):
|
||||||
|
"""Request to push raw LED colors to an api_input source.
|
||||||
|
|
||||||
|
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
|
||||||
|
At least one must be provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
|
||||||
|
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _require_colors_or_segments(self) -> "ColorPushRequest":
|
||||||
|
if self.colors is None and self.segments is None:
|
||||||
|
raise ValueError("Either 'colors' or 'segments' must be provided")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class NotifyRequest(BaseModel):
|
class NotifyRequest(BaseModel):
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
|
|||||||
buffers the latest frame and serves it to targets. When no data has been
|
buffers the latest frame and serves it to targets. When no data has been
|
||||||
received within `timeout` seconds, LEDs revert to `fallback_color`.
|
received within `timeout` seconds, LEDs revert to `fallback_color`.
|
||||||
|
|
||||||
Thread-safe: push_colors() can be called from any thread (REST handler,
|
Thread-safe: push_colors() / push_segments() can be called from any thread
|
||||||
WebSocket handler) while get_latest_colors() is called from the target
|
(REST handler, WebSocket handler) while get_latest_colors() is called from
|
||||||
processor thread.
|
the target processor thread.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_LED_COUNT = 150
|
||||||
|
|
||||||
|
|
||||||
class ApiInputColorStripStream(ColorStripStream):
|
class ApiInputColorStripStream(ColorStripStream):
|
||||||
"""Color strip stream backed by externally-pushed LED color data.
|
"""Color strip stream backed by externally-pushed LED color data.
|
||||||
|
|
||||||
Holds a thread-safe np.ndarray buffer. External clients push colors via
|
Holds a thread-safe np.ndarray buffer. External clients push colors via
|
||||||
push_colors(). A background thread checks for timeout and reverts to
|
push_colors() or push_segments(). A background thread checks for timeout
|
||||||
fallback_color when no data arrives within the configured timeout window.
|
and reverts to fallback_color when no data arrives within the configured
|
||||||
|
timeout window.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source):
|
||||||
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
fallback = source.fallback_color
|
fallback = source.fallback_color
|
||||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
||||||
self._auto_size = not source.led_count
|
self._led_count = _DEFAULT_LED_COUNT
|
||||||
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
|
||||||
|
|
||||||
# Build initial fallback buffer
|
# Build initial fallback buffer
|
||||||
self._fallback_array = self._build_fallback(self._led_count)
|
self._fallback_array = self._build_fallback(self._led_count)
|
||||||
self._colors = self._fallback_array.copy()
|
self._colors = self._fallback_array.copy()
|
||||||
self._last_push_time: float = 0.0
|
self._last_push_time: float = 0.0
|
||||||
self._timed_out = True # Start in timed-out state
|
self._timed_out = True # Start in timed-out state
|
||||||
|
self._push_generation: int = 0 # Incremented on each push; used by test WS
|
||||||
|
|
||||||
def _build_fallback(self, led_count: int) -> np.ndarray:
|
def _build_fallback(self, led_count: int) -> np.ndarray:
|
||||||
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
|
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
|
||||||
@@ -59,40 +62,124 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
(led_count, 1),
|
(led_count, 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _ensure_capacity(self, required: int) -> None:
|
||||||
|
"""Grow the buffer to at least `required` LEDs (must be called under lock)."""
|
||||||
|
if required > self._led_count:
|
||||||
|
self._led_count = required
|
||||||
|
self._fallback_array = self._build_fallback(self._led_count)
|
||||||
|
# Preserve existing data if not timed out
|
||||||
|
if not self._timed_out:
|
||||||
|
new_buf = self._fallback_array.copy()
|
||||||
|
old_len = min(len(self._colors), required)
|
||||||
|
new_buf[:old_len] = self._colors[:old_len]
|
||||||
|
self._colors = new_buf
|
||||||
|
else:
|
||||||
|
self._colors = self._fallback_array.copy()
|
||||||
|
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
|
||||||
|
|
||||||
def push_colors(self, colors: np.ndarray) -> None:
|
def push_colors(self, colors: np.ndarray) -> None:
|
||||||
"""Push a new frame of LED colors.
|
"""Push a new frame of LED colors.
|
||||||
|
|
||||||
Thread-safe. The array is truncated or zero-padded to match led_count.
|
Thread-safe. Auto-grows the buffer if the incoming array is larger
|
||||||
|
than the current buffer; otherwise truncates or zero-pads.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
colors: np.ndarray shape (N, 3) uint8
|
colors: np.ndarray shape (N, 3) uint8
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
n = len(colors)
|
n = len(colors)
|
||||||
|
# Auto-grow if incoming data is larger
|
||||||
|
if n > self._led_count:
|
||||||
|
self._ensure_capacity(n)
|
||||||
if n == self._led_count:
|
if n == self._led_count:
|
||||||
self._colors = colors.astype(np.uint8)
|
self._colors = colors.astype(np.uint8)
|
||||||
elif n > self._led_count:
|
elif n < self._led_count:
|
||||||
self._colors = colors[:self._led_count].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
# Zero-pad to led_count
|
# Zero-pad to led_count
|
||||||
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
|
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||||
padded[:n] = colors[:n]
|
padded[:n] = colors[:n]
|
||||||
self._colors = padded
|
self._colors = padded
|
||||||
self._last_push_time = time.monotonic()
|
self._last_push_time = time.monotonic()
|
||||||
|
self._push_generation += 1
|
||||||
|
self._timed_out = False
|
||||||
|
|
||||||
|
def push_segments(self, segments: list) -> None:
|
||||||
|
"""Apply segment-based color updates to the buffer.
|
||||||
|
|
||||||
|
Each segment defines a range and fill mode. Segments are applied in
|
||||||
|
order (last wins on overlap). The buffer is auto-grown if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segments: list of dicts with keys:
|
||||||
|
start (int) – starting LED index
|
||||||
|
length (int) – number of LEDs in segment
|
||||||
|
mode (str) – "solid" | "per_pixel" | "gradient"
|
||||||
|
color (list) – [R,G,B] for solid mode
|
||||||
|
colors (list) – [[R,G,B], ...] for per_pixel/gradient
|
||||||
|
"""
|
||||||
|
# Compute required buffer size from all segments
|
||||||
|
max_index = max(seg["start"] + seg["length"] for seg in segments)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Auto-grow buffer if needed
|
||||||
|
if max_index > self._led_count:
|
||||||
|
self._ensure_capacity(max_index)
|
||||||
|
|
||||||
|
# Start from current buffer (or fallback if timed out)
|
||||||
|
if self._timed_out:
|
||||||
|
buf = self._fallback_array.copy()
|
||||||
|
else:
|
||||||
|
buf = self._colors.copy()
|
||||||
|
|
||||||
|
for seg in segments:
|
||||||
|
start = seg["start"]
|
||||||
|
length = seg["length"]
|
||||||
|
mode = seg["mode"]
|
||||||
|
end = start + length
|
||||||
|
|
||||||
|
if mode == "solid":
|
||||||
|
color = np.array(seg["color"], dtype=np.uint8)
|
||||||
|
buf[start:end] = color
|
||||||
|
|
||||||
|
elif mode == "per_pixel":
|
||||||
|
colors = np.array(seg["colors"], dtype=np.uint8)
|
||||||
|
available = len(colors)
|
||||||
|
if available >= length:
|
||||||
|
buf[start:end] = colors[:length]
|
||||||
|
else:
|
||||||
|
# Pad with zeros if fewer colors than length
|
||||||
|
buf[start:start + available] = colors
|
||||||
|
buf[start + available:end] = 0
|
||||||
|
|
||||||
|
elif mode == "gradient":
|
||||||
|
stops = np.array(seg["colors"], dtype=np.float32)
|
||||||
|
num_stops = len(stops)
|
||||||
|
# Positions of stops evenly spaced 0..length-1
|
||||||
|
stop_positions = np.linspace(0, length - 1, num_stops)
|
||||||
|
pixel_positions = np.arange(length, dtype=np.float32)
|
||||||
|
for ch in range(3):
|
||||||
|
buf[start:end, ch] = np.interp(
|
||||||
|
pixel_positions,
|
||||||
|
stop_positions,
|
||||||
|
stops[:, ch],
|
||||||
|
).astype(np.uint8)
|
||||||
|
|
||||||
|
self._colors = buf
|
||||||
|
self._last_push_time = time.monotonic()
|
||||||
|
self._push_generation += 1
|
||||||
self._timed_out = False
|
self._timed_out = False
|
||||||
|
|
||||||
def configure(self, device_led_count: int) -> None:
|
def configure(self, device_led_count: int) -> None:
|
||||||
"""Set LED count from the target device (called on target start).
|
"""Set LED count from the target device (called on target start).
|
||||||
|
|
||||||
Only takes effect when led_count was 0 (auto-size).
|
Always resizes the buffer to the device LED count.
|
||||||
"""
|
"""
|
||||||
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
|
if device_led_count > 0 and device_led_count != self._led_count:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._led_count = device_led_count
|
self._led_count = device_led_count
|
||||||
self._fallback_array = self._build_fallback(device_led_count)
|
self._fallback_array = self._build_fallback(device_led_count)
|
||||||
self._colors = self._fallback_array.copy()
|
self._colors = self._fallback_array.copy()
|
||||||
self._timed_out = True
|
self._timed_out = True
|
||||||
logger.debug(f"ApiInputColorStripStream auto-sized to {device_led_count} LEDs")
|
logger.debug(f"ApiInputColorStripStream configured to {device_led_count} LEDs")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target_fps(self) -> int:
|
def target_fps(self) -> int:
|
||||||
@@ -131,6 +218,11 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._colors
|
return self._colors
|
||||||
|
|
||||||
|
@property
|
||||||
|
def push_generation(self) -> int:
|
||||||
|
"""Monotonically increasing counter, bumped on each push_colors/push_segments."""
|
||||||
|
return self._push_generation
|
||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
"""Hot-update fallback_color and timeout from updated source config."""
|
"""Hot-update fallback_color and timeout from updated source config."""
|
||||||
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
||||||
@@ -138,15 +230,6 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
fallback = source.fallback_color
|
fallback = source.fallback_color
|
||||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
|
||||||
self._auto_size = not source.led_count
|
|
||||||
with self._lock:
|
|
||||||
self._fallback_array = self._build_fallback(self._led_count)
|
|
||||||
if self._timed_out:
|
|
||||||
self._colors = self._fallback_array.copy()
|
|
||||||
# Preserve runtime LED count across updates if auto-sized
|
|
||||||
if prev_led_count and self._auto_size:
|
|
||||||
self._led_count = prev_led_count
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._fallback_array = self._build_fallback(self._led_count)
|
self._fallback_array = self._build_fallback(self._led_count)
|
||||||
if self._timed_out:
|
if self._timed_out:
|
||||||
|
|||||||
@@ -269,6 +269,8 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
|
||||||
|
|
||||||
/* Composite layers preview */
|
/* Composite layers preview */
|
||||||
.css-test-layers {
|
.css-test-layers {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -346,7 +348,107 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Log viewer ─────────────────────────────────────────────── */
|
/* ── Settings modal tabs ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.settings-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: color 0.2s ease, border-color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab-btn.active {
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel.active {
|
||||||
|
display: block;
|
||||||
|
animation: tabFadeIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Log viewer overlay (full-screen) ──────────────────────── */
|
||||||
|
|
||||||
|
.log-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-color, #111);
|
||||||
|
padding: 12px 16px;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-overlay-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-overlay-close:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-overlay-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
padding-right: 36px; /* space for corner close btn */
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-overlay-toolbar h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-overlay .log-viewer-output {
|
||||||
|
flex: 1;
|
||||||
|
max-height: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Log viewer base ───────────────────────────────────────── */
|
||||||
|
|
||||||
.log-viewer-output {
|
.log-viewer-output {
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
|
|||||||
@@ -182,12 +182,14 @@ import { switchTab, initTabs, startAutoRefresh, handlePopState } from './feature
|
|||||||
import { navigateToCard } from './core/navigation.js';
|
import { navigateToCard } from './core/navigation.js';
|
||||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||||
import {
|
import {
|
||||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||||
|
downloadBackup, handleRestoreFileSelected,
|
||||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||||
restartServer, saveMqttSettings,
|
restartServer, saveMqttSettings,
|
||||||
loadApiKeysList,
|
loadApiKeysList,
|
||||||
downloadPartialExport, handlePartialImportFileSelected,
|
downloadPartialExport, handlePartialImportFileSelected,
|
||||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||||
|
openLogOverlay, closeLogOverlay,
|
||||||
loadLogLevel, setLogLevel,
|
loadLogLevel, setLogLevel,
|
||||||
} from './features/settings.js';
|
} from './features/settings.js';
|
||||||
|
|
||||||
@@ -522,9 +524,10 @@ Object.assign(window, {
|
|||||||
openCommandPalette,
|
openCommandPalette,
|
||||||
closeCommandPalette,
|
closeCommandPalette,
|
||||||
|
|
||||||
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
|
// settings (tabs / backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
|
||||||
openSettingsModal,
|
openSettingsModal,
|
||||||
closeSettingsModal,
|
closeSettingsModal,
|
||||||
|
switchSettingsTab,
|
||||||
downloadBackup,
|
downloadBackup,
|
||||||
handleRestoreFileSelected,
|
handleRestoreFileSelected,
|
||||||
saveAutoBackupSettings,
|
saveAutoBackupSettings,
|
||||||
@@ -540,6 +543,8 @@ Object.assign(window, {
|
|||||||
disconnectLogViewer,
|
disconnectLogViewer,
|
||||||
clearLogViewer,
|
clearLogViewer,
|
||||||
applyLogFilter,
|
applyLogFilter,
|
||||||
|
openLogOverlay,
|
||||||
|
closeLogOverlay,
|
||||||
loadLogLevel,
|
loadLogLevel,
|
||||||
setLogLevel,
|
setLogLevel,
|
||||||
});
|
});
|
||||||
@@ -569,8 +574,11 @@ document.addEventListener('keydown', (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// Close in order: overlay lightboxes first, then modals via stack
|
// Close in order: log overlay > overlay lightboxes > modals via stack
|
||||||
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
const logOverlay = document.getElementById('log-overlay');
|
||||||
|
if (logOverlay && logOverlay.style.display !== 'none') {
|
||||||
|
closeLogOverlay();
|
||||||
|
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||||
closeDisplayPicker();
|
closeDisplayPicker();
|
||||||
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
||||||
closeLightbox();
|
closeLightbox();
|
||||||
|
|||||||
@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
|
|||||||
pattern_template: patternTemplatesCache,
|
pattern_template: patternTemplatesCache,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Maps entity_type to the window load function that refreshes its UI. */
|
||||||
|
const ENTITY_LOADER_MAP = {
|
||||||
|
device: 'loadTargetsTab',
|
||||||
|
output_target: 'loadTargetsTab',
|
||||||
|
color_strip_source: 'loadTargetsTab',
|
||||||
|
pattern_template: 'loadTargetsTab',
|
||||||
|
picture_source: 'loadPictureSources',
|
||||||
|
audio_source: 'loadPictureSources',
|
||||||
|
value_source: 'loadPictureSources',
|
||||||
|
sync_clock: 'loadPictureSources',
|
||||||
|
capture_template: 'loadPictureSources',
|
||||||
|
audio_template: 'loadPictureSources',
|
||||||
|
pp_template: 'loadPictureSources',
|
||||||
|
automation: 'loadAutomations',
|
||||||
|
scene_preset: 'loadAutomations',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Debounce timers per loader function name — coalesces rapid WS events and
|
||||||
|
* avoids a redundant re-render when the local save handler already triggered one. */
|
||||||
|
const _loaderTimers = {};
|
||||||
|
const _LOADER_DEBOUNCE_MS = 600;
|
||||||
|
|
||||||
function _invalidateAndReload(entityType) {
|
function _invalidateAndReload(entityType) {
|
||||||
const cache = ENTITY_CACHE_MAP[entityType];
|
const cache = ENTITY_CACHE_MAP[entityType];
|
||||||
if (cache) {
|
if (!cache) return;
|
||||||
cache.fetch({ force: true });
|
|
||||||
|
const oldData = cache.data;
|
||||||
|
cache.fetch({ force: true }).then((newData) => {
|
||||||
|
// Skip UI refresh if the data didn't actually change —
|
||||||
|
// the local save handler already refreshed the UI.
|
||||||
|
if (oldData === newData) return;
|
||||||
|
if (Array.isArray(oldData) && Array.isArray(newData) &&
|
||||||
|
oldData.length === newData.length &&
|
||||||
|
JSON.stringify(oldData) === JSON.stringify(newData)) return;
|
||||||
|
|
||||||
|
const loader = ENTITY_LOADER_MAP[entityType];
|
||||||
|
if (loader) {
|
||||||
|
clearTimeout(_loaderTimers[loader]);
|
||||||
|
_loaderTimers[loader] = setTimeout(() => {
|
||||||
|
delete _loaderTimers[loader];
|
||||||
|
if (typeof window[loader] === 'function') window[loader]();
|
||||||
|
}, _LOADER_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent('entity:reload', {
|
document.dispatchEvent(new CustomEvent('entity:reload', {
|
||||||
detail: { entity_type: entityType },
|
detail: { entity_type: entityType },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* This module manages the editor modal and API operations.
|
* This module manages the editor modal and API operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { _cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js';
|
import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.js';
|
||||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
||||||
@@ -155,6 +155,7 @@ export async function saveAudioSource() {
|
|||||||
}
|
}
|
||||||
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
|
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
|
||||||
audioSourceModal.forceClose();
|
audioSourceModal.forceClose();
|
||||||
|
audioSourcesCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorEl.textContent = e.message;
|
errorEl.textContent = e.message;
|
||||||
@@ -205,6 +206,7 @@ export async function deleteAudioSource(sourceId) {
|
|||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
}
|
}
|
||||||
showToast(t('audio_source.deleted'), 'success');
|
showToast(t('audio_source.deleted'), 'success');
|
||||||
|
audioSourcesCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
|
|||||||
@@ -706,6 +706,7 @@ export async function saveAutomationEditor() {
|
|||||||
|
|
||||||
automationModal.forceClose();
|
automationModal.forceClose();
|
||||||
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
|
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
|
||||||
|
automationsCacheObj.invalidate();
|
||||||
loadAutomations();
|
loadAutomations();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -720,6 +721,7 @@ export async function toggleAutomationEnabled(automationId, enable) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
|
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
|
||||||
|
automationsCacheObj.invalidate();
|
||||||
loadAutomations();
|
loadAutomations();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -767,6 +769,7 @@ export async function deleteAutomation(automationId, automationName) {
|
|||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Failed to delete automation');
|
if (!resp.ok) throw new Error('Failed to delete automation');
|
||||||
showToast(t('automations.deleted'), 'success');
|
showToast(t('automations.deleted'), 'success');
|
||||||
|
automationsCacheObj.invalidate();
|
||||||
loadAutomations();
|
loadAutomations();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
|
import { createFpsSparkline } from '../core/chart-utils.js';
|
||||||
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
|
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||||
@@ -198,8 +199,8 @@ export function onCSSTypeChange() {
|
|||||||
}
|
}
|
||||||
_syncAnimationSpeedState();
|
_syncAnimationSpeedState();
|
||||||
|
|
||||||
// LED count — only shown for picture, picture_advanced, api_input
|
// LED count — only shown for picture, picture_advanced
|
||||||
const hasLedCount = ['picture', 'picture_advanced', 'api_input'];
|
const hasLedCount = ['picture', 'picture_advanced'];
|
||||||
document.getElementById('css-editor-led-count-group').style.display =
|
document.getElementById('css-editor-led-count-group').style.display =
|
||||||
hasLedCount.includes(type) ? '' : 'none';
|
hasLedCount.includes(type) ? '' : 'none';
|
||||||
|
|
||||||
@@ -1656,9 +1657,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
|||||||
const notifHistoryBtn = isNotification
|
const notifHistoryBtn = isNotification
|
||||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||||
: '';
|
: '';
|
||||||
const testPreviewBtn = !isApiInput
|
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
|
||||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return wrapCard({
|
return wrapCard({
|
||||||
dataAttr: 'data-css-id',
|
dataAttr: 'data-css-id',
|
||||||
@@ -2259,6 +2258,7 @@ export async function saveCSSEditor() {
|
|||||||
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
||||||
colorStripSourcesCache.invalidate();
|
colorStripSourcesCache.invalidate();
|
||||||
cssEditorModal.forceClose();
|
cssEditorModal.forceClose();
|
||||||
|
if (window.loadPictureSources) window.loadPictureSources();
|
||||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
@@ -2335,6 +2335,7 @@ export async function deleteColorStrip(cssId) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast(t('color_strip.deleted'), 'success');
|
showToast(t('color_strip.deleted'), 'success');
|
||||||
colorStripSourcesCache.invalidate();
|
colorStripSourcesCache.invalidate();
|
||||||
|
if (window.loadPictureSources) window.loadPictureSources();
|
||||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||||
} else {
|
} else {
|
||||||
const err = await response.json();
|
const err = await response.json();
|
||||||
@@ -2476,6 +2477,11 @@ let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messa
|
|||||||
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
|
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
|
||||||
let _cssTestCSPTMode = false; // true when testing a CSPT template
|
let _cssTestCSPTMode = false; // true when testing a CSPT template
|
||||||
let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode
|
let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode
|
||||||
|
let _cssTestIsApiInput = false;
|
||||||
|
let _cssTestFpsTimestamps = []; // raw timestamps for current-second FPS calculation
|
||||||
|
let _cssTestFpsActualHistory = []; // rolling FPS samples for sparkline
|
||||||
|
let _cssTestFpsChart = null;
|
||||||
|
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
|
||||||
let _csptTestInputEntitySelect = null;
|
let _csptTestInputEntitySelect = null;
|
||||||
|
|
||||||
function _getCssTestLedCount() {
|
function _getCssTestLedCount() {
|
||||||
@@ -2488,12 +2494,32 @@ function _getCssTestFps() {
|
|||||||
return (stored >= 1 && stored <= 60) ? stored : 20;
|
return (stored >= 1 && stored <= 60) ? stored : 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _populateCssTestSourceSelector(preselectId) {
|
||||||
|
const sources = colorStripSourcesCache.data || [];
|
||||||
|
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
|
||||||
|
const sel = document.getElementById('css-test-cspt-input-select');
|
||||||
|
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 || [])
|
||||||
|
.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) {
|
export function testColorStrip(sourceId) {
|
||||||
_cssTestCSPTMode = false;
|
_cssTestCSPTMode = false;
|
||||||
_cssTestCSPTId = null;
|
_cssTestCSPTId = null;
|
||||||
// Hide CSPT input selector
|
// Detect api_input type
|
||||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
const sources = colorStripSourcesCache.data || [];
|
||||||
if (csptGroup) csptGroup.style.display = 'none';
|
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);
|
_openTestModal(sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2503,25 +2529,9 @@ export async function testCSPT(templateId) {
|
|||||||
|
|
||||||
// Populate input source selector
|
// Populate input source selector
|
||||||
await colorStripSourcesCache.fetch();
|
await colorStripSourcesCache.fetch();
|
||||||
const sources = colorStripSourcesCache.data || [];
|
_populateCssTestSourceSelector(null);
|
||||||
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
|
|
||||||
const sel = document.getElementById('css-test-cspt-input-select');
|
|
||||||
sel.innerHTML = nonProcessed.map(s =>
|
|
||||||
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
|
|
||||||
).join('');
|
|
||||||
// EntitySelect for input source picker
|
|
||||||
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
|
|
||||||
_csptTestInputEntitySelect = new EntitySelect({
|
|
||||||
target: sel,
|
|
||||||
getItems: () => (colorStripSourcesCache.data || [])
|
|
||||||
.filter(s => s.source_type !== 'processed')
|
|
||||||
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
|
|
||||||
placeholder: t('palette.search'),
|
|
||||||
});
|
|
||||||
// Show CSPT input selector
|
|
||||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
|
||||||
if (csptGroup) csptGroup.style.display = '';
|
|
||||||
|
|
||||||
|
const sel = document.getElementById('css-test-cspt-input-select');
|
||||||
const inputId = sel.value;
|
const inputId = sel.value;
|
||||||
if (!inputId) {
|
if (!inputId) {
|
||||||
showToast(t('color_strip.processed.error.no_input'), 'error');
|
showToast(t('color_strip.processed.error.no_input'), 'error');
|
||||||
@@ -2550,11 +2560,29 @@ function _openTestModal(sourceId) {
|
|||||||
document.getElementById('css-test-rect-view').style.display = 'none';
|
document.getElementById('css-test-rect-view').style.display = 'none';
|
||||||
document.getElementById('css-test-layers-view').style.display = 'none';
|
document.getElementById('css-test-layers-view').style.display = 'none';
|
||||||
document.getElementById('css-test-led-group').style.display = '';
|
document.getElementById('css-test-led-group').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');
|
||||||
|
if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
|
||||||
const layersContainer = document.getElementById('css-test-layers');
|
const layersContainer = document.getElementById('css-test-layers');
|
||||||
if (layersContainer) layersContainer.innerHTML = '';
|
if (layersContainer) layersContainer.innerHTML = '';
|
||||||
document.getElementById('css-test-status').style.display = '';
|
document.getElementById('css-test-status').style.display = '';
|
||||||
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
|
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
|
||||||
|
|
||||||
|
// Reset FPS tracking
|
||||||
|
_cssTestFpsHistory = [];
|
||||||
|
|
||||||
|
// For api_input: hide LED/FPS controls, show FPS chart
|
||||||
|
const ledControlGroup = document.getElementById('css-test-led-fps-group');
|
||||||
|
const fpsChartGroup = document.getElementById('css-test-fps-chart-group');
|
||||||
|
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
|
// Restore LED count + FPS + Enter key handlers
|
||||||
const ledCount = _getCssTestLedCount();
|
const ledCount = _getCssTestLedCount();
|
||||||
const ledInput = document.getElementById('css-test-led-input');
|
const ledInput = document.getElementById('css-test-led-input');
|
||||||
@@ -2568,6 +2596,7 @@ function _openTestModal(sourceId) {
|
|||||||
|
|
||||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _cssTestConnect(sourceId, ledCount, fps) {
|
function _cssTestConnect(sourceId, ledCount, fps) {
|
||||||
// Close existing connection if any
|
// Close existing connection if any
|
||||||
@@ -2714,6 +2743,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
|||||||
// Standard format: raw RGB
|
// Standard format: raw RGB
|
||||||
_cssTestLatestRgb = raw;
|
_cssTestLatestRgb = raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track FPS for api_input sources
|
||||||
|
if (_cssTestIsApiInput) {
|
||||||
|
_cssTestFpsTimestamps.push(performance.now());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2805,12 +2839,14 @@ export function applyCssTestSettings() {
|
|||||||
_cssTestMeta = null;
|
_cssTestMeta = null;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
|
||||||
// In CSPT mode, read selected input source
|
// Read selected input source from selector (both CSS and CSPT modes)
|
||||||
if (_cssTestCSPTMode) {
|
|
||||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||||
if (inputSel && inputSel.value) {
|
if (inputSel && inputSel.value) {
|
||||||
_cssTestSourceId = inputSel.value;
|
_cssTestSourceId = inputSel.value;
|
||||||
}
|
// Re-detect api_input when source changes
|
||||||
|
const sources = colorStripSourcesCache.data || [];
|
||||||
|
const src = sources.find(s => s.id === _cssTestSourceId);
|
||||||
|
_cssTestIsApiInput = src?.source_type === 'api_input';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnect (generation counter ignores stale frames from old WS)
|
// Reconnect (generation counter ignores stale frames from old WS)
|
||||||
@@ -3162,6 +3198,60 @@ export function fireCssTestNotificationLayer(sourceId) {
|
|||||||
testNotification(sourceId);
|
testNotification(sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _cssTestFpsSampleInterval = 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');
|
||||||
|
if (valueEl) valueEl.textContent = fps;
|
||||||
|
const avgEl = document.getElementById('css-test-fps-avg');
|
||||||
|
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() {
|
export function closeTestCssSourceModal() {
|
||||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||||
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
||||||
@@ -3171,6 +3261,10 @@ export function closeTestCssSourceModal() {
|
|||||||
_cssTestIsComposite = false;
|
_cssTestIsComposite = false;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
_cssTestNotificationIds = [];
|
_cssTestNotificationIds = [];
|
||||||
|
_cssTestIsApiInput = false;
|
||||||
|
_cssTestStopFpsSampling();
|
||||||
|
_cssTestFpsTimestamps = [];
|
||||||
|
_cssTestFpsActualHistory = [];
|
||||||
// Revoke blob URL for frame preview
|
// Revoke blob URL for frame preview
|
||||||
const screen = document.getElementById('css-test-rect-screen');
|
const screen = document.getElementById('css-test-rect-screen');
|
||||||
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }
|
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ export async function saveScenePreset() {
|
|||||||
|
|
||||||
scenePresetModal.forceClose();
|
scenePresetModal.forceClose();
|
||||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||||
|
scenePresetsCache.invalidate();
|
||||||
_reloadScenesTab();
|
_reloadScenesTab();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
@@ -348,6 +349,7 @@ export async function recaptureScenePreset(presetId) {
|
|||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showToast(t('scenes.recaptured'), 'success');
|
showToast(t('scenes.recaptured'), 'success');
|
||||||
|
scenePresetsCache.invalidate();
|
||||||
_reloadScenesTab();
|
_reloadScenesTab();
|
||||||
} else {
|
} else {
|
||||||
showToast(t('scenes.error.recapture_failed'), 'error');
|
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||||
@@ -420,6 +422,7 @@ export async function deleteScenePreset(presetId) {
|
|||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
showToast(t('scenes.deleted'), 'success');
|
showToast(t('scenes.deleted'), 'success');
|
||||||
|
scenePresetsCache.invalidate();
|
||||||
_reloadScenesTab();
|
_reloadScenesTab();
|
||||||
} else {
|
} else {
|
||||||
showToast(t('scenes.error.delete_failed'), 'error');
|
showToast(t('scenes.error.delete_failed'), 'error');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Settings — backup / restore configuration.
|
* Settings — tabbed modal (General / Backup / MQTT) + full-screen Log overlay.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiKey } from '../core/state.js';
|
import { apiKey } from '../core/state.js';
|
||||||
@@ -10,6 +10,17 @@ import { t } from '../core/i18n.js';
|
|||||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
||||||
import { IconSelect } from '../core/icon-select.js';
|
import { IconSelect } from '../core/icon-select.js';
|
||||||
|
|
||||||
|
// ─── Settings-modal tab switching ───────────────────────────
|
||||||
|
|
||||||
|
export function switchSettingsTab(tabId) {
|
||||||
|
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.settingsTab === tabId);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.settings-panel').forEach(panel => {
|
||||||
|
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Log Viewer ────────────────────────────────────────────
|
// ─── Log Viewer ────────────────────────────────────────────
|
||||||
|
|
||||||
/** @type {WebSocket|null} */
|
/** @type {WebSocket|null} */
|
||||||
@@ -114,9 +125,6 @@ export function clearLogViewer() {
|
|||||||
|
|
||||||
/** Re-render the log output according to the current filter selection. */
|
/** Re-render the log output according to the current filter selection. */
|
||||||
export function applyLogFilter() {
|
export function applyLogFilter() {
|
||||||
// We don't buffer all raw lines in JS — just clear and note the filter
|
|
||||||
// will apply to future lines. Existing lines that were already rendered
|
|
||||||
// are re-evaluated by toggling their visibility.
|
|
||||||
const output = document.getElementById('log-viewer-output');
|
const output = document.getElementById('log-viewer-output');
|
||||||
if (!output) return;
|
if (!output) return;
|
||||||
const filter = _filterLevel();
|
const filter = _filterLevel();
|
||||||
@@ -128,50 +136,84 @@ export function applyLogFilter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Log Overlay (full-screen) ──────────────────────────────
|
||||||
|
|
||||||
|
let _logFilterIconSelect = null;
|
||||||
|
|
||||||
|
/** Build filter items lazily so t() has locale data loaded. */
|
||||||
|
function _getLogFilterItems() {
|
||||||
|
return [
|
||||||
|
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
|
||||||
|
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
|
||||||
|
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
|
||||||
|
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openLogOverlay() {
|
||||||
|
const overlay = document.getElementById('log-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
|
||||||
|
// Initialize log filter icon select (once)
|
||||||
|
if (!_logFilterIconSelect) {
|
||||||
|
const filterSel = document.getElementById('log-viewer-filter');
|
||||||
|
if (filterSel) {
|
||||||
|
_logFilterIconSelect = new IconSelect({
|
||||||
|
target: filterSel,
|
||||||
|
items: _getLogFilterItems(),
|
||||||
|
columns: 2,
|
||||||
|
onChange: () => applyLogFilter(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-connect when opening
|
||||||
|
if (!_logWs || _logWs.readyState !== WebSocket.OPEN) {
|
||||||
|
connectLogViewer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeLogOverlay() {
|
||||||
|
const overlay = document.getElementById('log-overlay');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
disconnectLogViewer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings Modal ─────────────────────────────────────────
|
||||||
|
|
||||||
// Simple modal (no form / no dirty check needed)
|
// Simple modal (no form / no dirty check needed)
|
||||||
const settingsModal = new Modal('settings-modal');
|
const settingsModal = new Modal('settings-modal');
|
||||||
|
|
||||||
let _logFilterIconSelect = null;
|
|
||||||
let _logLevelIconSelect = null;
|
let _logLevelIconSelect = null;
|
||||||
|
|
||||||
const _LOG_LEVEL_ITEMS = [
|
/** Build log-level items lazily so t() has locale data loaded. */
|
||||||
|
function _getLogLevelItems() {
|
||||||
|
return [
|
||||||
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
|
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
|
||||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
|
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
|
||||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
|
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
|
||||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
|
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
|
||||||
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
|
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
|
||||||
];
|
];
|
||||||
|
}
|
||||||
const _LOG_FILTER_ITEMS = [
|
|
||||||
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
|
|
||||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
|
|
||||||
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
|
|
||||||
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function openSettingsModal() {
|
export function openSettingsModal() {
|
||||||
document.getElementById('settings-error').style.display = 'none';
|
document.getElementById('settings-error').style.display = 'none';
|
||||||
|
|
||||||
|
// Reset to first tab
|
||||||
|
switchSettingsTab('general');
|
||||||
|
|
||||||
settingsModal.open();
|
settingsModal.open();
|
||||||
|
|
||||||
// Initialize log filter icon select
|
|
||||||
if (!_logFilterIconSelect) {
|
|
||||||
const filterSel = document.getElementById('log-viewer-filter');
|
|
||||||
if (filterSel) {
|
|
||||||
_logFilterIconSelect = new IconSelect({
|
|
||||||
target: filterSel,
|
|
||||||
items: _LOG_FILTER_ITEMS,
|
|
||||||
columns: 2,
|
|
||||||
onChange: () => applyLogFilter(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Initialize log level icon select
|
// Initialize log level icon select
|
||||||
if (!_logLevelIconSelect) {
|
if (!_logLevelIconSelect) {
|
||||||
const levelSel = document.getElementById('settings-log-level');
|
const levelSel = document.getElementById('settings-log-level');
|
||||||
if (levelSel) {
|
if (levelSel) {
|
||||||
_logLevelIconSelect = new IconSelect({
|
_logLevelIconSelect = new IconSelect({
|
||||||
target: levelSel,
|
target: levelSel,
|
||||||
items: _LOG_LEVEL_ITEMS,
|
items: _getLogLevelItems(),
|
||||||
columns: 3,
|
columns: 3,
|
||||||
onChange: () => setLogLevel(),
|
onChange: () => setLogLevel(),
|
||||||
});
|
});
|
||||||
@@ -186,7 +228,6 @@ export function openSettingsModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function closeSettingsModal() {
|
export function closeSettingsModal() {
|
||||||
disconnectLogViewer();
|
|
||||||
settingsModal.forceClose();
|
settingsModal.forceClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -710,6 +710,7 @@ export async function saveTemplate() {
|
|||||||
|
|
||||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||||
templateModal.forceClose();
|
templateModal.forceClose();
|
||||||
|
captureTemplatesCache.invalidate();
|
||||||
await loadCaptureTemplates();
|
await loadCaptureTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving template:', error);
|
console.error('Error saving template:', error);
|
||||||
@@ -729,6 +730,7 @@ export async function deleteTemplate(templateId) {
|
|||||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||||
}
|
}
|
||||||
showToast(t('templates.deleted'), 'success');
|
showToast(t('templates.deleted'), 'success');
|
||||||
|
captureTemplatesCache.invalidate();
|
||||||
await loadCaptureTemplates();
|
await loadCaptureTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting template:', error);
|
console.error('Error deleting template:', error);
|
||||||
@@ -970,6 +972,7 @@ export async function saveAudioTemplate() {
|
|||||||
|
|
||||||
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
||||||
audioTemplateModal.forceClose();
|
audioTemplateModal.forceClose();
|
||||||
|
audioTemplatesCache.invalidate();
|
||||||
await loadAudioTemplates();
|
await loadAudioTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving audio template:', error);
|
console.error('Error saving audio template:', error);
|
||||||
@@ -989,6 +992,7 @@ export async function deleteAudioTemplate(templateId) {
|
|||||||
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
||||||
}
|
}
|
||||||
showToast(t('audio_template.deleted'), 'success');
|
showToast(t('audio_template.deleted'), 'success');
|
||||||
|
audioTemplatesCache.invalidate();
|
||||||
await loadAudioTemplates();
|
await loadAudioTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting audio template:', error);
|
console.error('Error deleting audio template:', error);
|
||||||
@@ -2089,6 +2093,7 @@ export async function saveStream() {
|
|||||||
|
|
||||||
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
||||||
streamModal.forceClose();
|
streamModal.forceClose();
|
||||||
|
streamsCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving stream:', error);
|
console.error('Error saving stream:', error);
|
||||||
@@ -2108,6 +2113,7 @@ export async function deleteStream(streamId) {
|
|||||||
throw new Error(error.detail || error.message || 'Failed to delete stream');
|
throw new Error(error.detail || error.message || 'Failed to delete stream');
|
||||||
}
|
}
|
||||||
showToast(t('streams.deleted'), 'success');
|
showToast(t('streams.deleted'), 'success');
|
||||||
|
streamsCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting stream:', error);
|
console.error('Error deleting stream:', error);
|
||||||
@@ -2675,6 +2681,7 @@ export async function savePPTemplate() {
|
|||||||
|
|
||||||
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||||||
ppTemplateModal.forceClose();
|
ppTemplateModal.forceClose();
|
||||||
|
ppTemplatesCache.invalidate();
|
||||||
await loadPPTemplates();
|
await loadPPTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving PP template:', error);
|
console.error('Error saving PP template:', error);
|
||||||
@@ -2735,6 +2742,7 @@ export async function deletePPTemplate(templateId) {
|
|||||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||||
}
|
}
|
||||||
showToast(t('postprocessing.deleted'), 'success');
|
showToast(t('postprocessing.deleted'), 'success');
|
||||||
|
ppTemplatesCache.invalidate();
|
||||||
await loadPPTemplates();
|
await loadPPTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting PP template:', error);
|
console.error('Error deleting PP template:', error);
|
||||||
@@ -2888,6 +2896,7 @@ export async function saveCSPT() {
|
|||||||
|
|
||||||
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
|
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
|
||||||
csptModal.forceClose();
|
csptModal.forceClose();
|
||||||
|
csptCache.invalidate();
|
||||||
await loadCSPTemplates();
|
await loadCSPTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving CSPT:', error);
|
console.error('Error saving CSPT:', error);
|
||||||
@@ -2920,6 +2929,7 @@ export async function deleteCSPT(templateId) {
|
|||||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||||
}
|
}
|
||||||
showToast(t('css_processing.deleted'), 'success');
|
showToast(t('css_processing.deleted'), 'success');
|
||||||
|
csptCache.invalidate();
|
||||||
await loadCSPTemplates();
|
await loadCSPTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting CSPT:', error);
|
console.error('Error deleting CSPT:', error);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export async function saveSyncClock() {
|
|||||||
}
|
}
|
||||||
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
||||||
syncClockModal.forceClose();
|
syncClockModal.forceClose();
|
||||||
|
syncClocksCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -143,6 +144,7 @@ export async function deleteSyncClock(clockId) {
|
|||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
}
|
}
|
||||||
showToast(t('sync_clock.deleted'), 'success');
|
showToast(t('sync_clock.deleted'), 'success');
|
||||||
|
syncClocksCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* This module manages the editor modal and API operations.
|
* This module manages the editor modal and API operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js';
|
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.js';
|
||||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
@@ -486,6 +486,7 @@ export async function saveValueSource() {
|
|||||||
}
|
}
|
||||||
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
|
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
|
||||||
valueSourceModal.forceClose();
|
valueSourceModal.forceClose();
|
||||||
|
valueSourcesCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorEl.textContent = e.message;
|
errorEl.textContent = e.message;
|
||||||
@@ -536,6 +537,7 @@ export async function deleteValueSource(sourceId) {
|
|||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
}
|
}
|
||||||
showToast(t('value_source.deleted'), 'success');
|
showToast(t('value_source.deleted'), 'success');
|
||||||
|
valueSourcesCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
|
|||||||
@@ -312,6 +312,10 @@
|
|||||||
"device.tip.webui": "Open the device's built-in web interface for advanced configuration",
|
"device.tip.webui": "Open the device's built-in web interface for advanced configuration",
|
||||||
"device.tip.add": "Click here to add a new LED device",
|
"device.tip.add": "Click here to add a new LED device",
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
|
"settings.tab.general": "General",
|
||||||
|
"settings.tab.backup": "Backup",
|
||||||
|
"settings.tab.mqtt": "MQTT",
|
||||||
|
"settings.logs.open_viewer": "Open Log Viewer",
|
||||||
"settings.general.title": "General Settings",
|
"settings.general.title": "General Settings",
|
||||||
"settings.capture.title": "Capture Settings",
|
"settings.capture.title": "Capture Settings",
|
||||||
"settings.capture.saved": "Capture settings updated",
|
"settings.capture.saved": "Capture settings updated",
|
||||||
@@ -1076,6 +1080,7 @@
|
|||||||
"color_strip.test.error": "Failed to connect to preview stream",
|
"color_strip.test.error": "Failed to connect to preview stream",
|
||||||
"color_strip.test.led_count": "LEDs:",
|
"color_strip.test.led_count": "LEDs:",
|
||||||
"color_strip.test.fps": "FPS:",
|
"color_strip.test.fps": "FPS:",
|
||||||
|
"color_strip.test.receive_fps": "Receive FPS",
|
||||||
"color_strip.test.apply": "Apply",
|
"color_strip.test.apply": "Apply",
|
||||||
"color_strip.test.composite": "Composite",
|
"color_strip.test.composite": "Composite",
|
||||||
"color_strip.preview.title": "Live Preview",
|
"color_strip.preview.title": "Live Preview",
|
||||||
|
|||||||
@@ -312,6 +312,10 @@
|
|||||||
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
|
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
|
||||||
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
|
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
|
||||||
"settings.title": "Настройки",
|
"settings.title": "Настройки",
|
||||||
|
"settings.tab.general": "Основные",
|
||||||
|
"settings.tab.backup": "Бэкап",
|
||||||
|
"settings.tab.mqtt": "MQTT",
|
||||||
|
"settings.logs.open_viewer": "Открыть логи",
|
||||||
"settings.general.title": "Основные Настройки",
|
"settings.general.title": "Основные Настройки",
|
||||||
"settings.capture.title": "Настройки Захвата",
|
"settings.capture.title": "Настройки Захвата",
|
||||||
"settings.capture.saved": "Настройки захвата обновлены",
|
"settings.capture.saved": "Настройки захвата обновлены",
|
||||||
@@ -1076,6 +1080,7 @@
|
|||||||
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
||||||
"color_strip.test.led_count": "Кол-во LED:",
|
"color_strip.test.led_count": "Кол-во LED:",
|
||||||
"color_strip.test.fps": "FPS:",
|
"color_strip.test.fps": "FPS:",
|
||||||
|
"color_strip.test.receive_fps": "Частота приёма",
|
||||||
"color_strip.test.apply": "Применить",
|
"color_strip.test.apply": "Применить",
|
||||||
"color_strip.test.composite": "Композит",
|
"color_strip.test.composite": "Композит",
|
||||||
"color_strip.preview.title": "Предпросмотр",
|
"color_strip.preview.title": "Предпросмотр",
|
||||||
|
|||||||
@@ -312,6 +312,10 @@
|
|||||||
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
|
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
|
||||||
"device.tip.add": "点击此处添加新的 LED 设备",
|
"device.tip.add": "点击此处添加新的 LED 设备",
|
||||||
"settings.title": "设置",
|
"settings.title": "设置",
|
||||||
|
"settings.tab.general": "常规",
|
||||||
|
"settings.tab.backup": "备份",
|
||||||
|
"settings.tab.mqtt": "MQTT",
|
||||||
|
"settings.logs.open_viewer": "打开日志查看器",
|
||||||
"settings.general.title": "常规设置",
|
"settings.general.title": "常规设置",
|
||||||
"settings.capture.title": "采集设置",
|
"settings.capture.title": "采集设置",
|
||||||
"settings.capture.saved": "采集设置已更新",
|
"settings.capture.saved": "采集设置已更新",
|
||||||
@@ -1076,6 +1080,7 @@
|
|||||||
"color_strip.test.error": "无法连接到预览流",
|
"color_strip.test.error": "无法连接到预览流",
|
||||||
"color_strip.test.led_count": "LED数量:",
|
"color_strip.test.led_count": "LED数量:",
|
||||||
"color_strip.test.fps": "FPS:",
|
"color_strip.test.fps": "FPS:",
|
||||||
|
"color_strip.test.receive_fps": "接收帧率",
|
||||||
"color_strip.test.apply": "应用",
|
"color_strip.test.apply": "应用",
|
||||||
"color_strip.test.composite": "合成",
|
"color_strip.test.composite": "合成",
|
||||||
"color_strip.preview.title": "实时预览",
|
"color_strip.preview.title": "实时预览",
|
||||||
|
|||||||
@@ -792,16 +792,14 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
|
External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
|
||||||
buffers the latest frame and serves it to targets. When no data has been
|
buffers the latest frame and serves it to targets. When no data has been
|
||||||
received within `timeout` seconds, LEDs revert to `fallback_color`.
|
received within `timeout` seconds, LEDs revert to `fallback_color`.
|
||||||
LED count auto-sizes from the connected device when led_count == 0.
|
LED count auto-sizes from the connected device via configure().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
led_count: int = 0 # 0 = auto-size from device
|
|
||||||
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
|
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
|
||||||
timeout: float = 5.0 # seconds before reverting to fallback
|
timeout: float = 5.0 # seconds before reverting to fallback
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["led_count"] = self.led_count
|
|
||||||
d["fallback_color"] = list(self.fallback_color)
|
d["fallback_color"] = list(self.fallback_color)
|
||||||
d["timeout"] = self.timeout
|
d["timeout"] = self.timeout
|
||||||
return d
|
return d
|
||||||
@@ -810,14 +808,14 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
description=None, clock_id=None, tags=None,
|
description=None, clock_id=None, tags=None,
|
||||||
led_count=0, fallback_color=None, timeout=None,
|
fallback_color=None, timeout=None,
|
||||||
**_kwargs):
|
**_kwargs):
|
||||||
fb = _validate_rgb(fallback_color, [0, 0, 0])
|
fb = _validate_rgb(fallback_color, [0, 0, 0])
|
||||||
return cls(
|
return cls(
|
||||||
id=id, name=name, source_type="api_input",
|
id=id, name=name, source_type="api_input",
|
||||||
created_at=created_at, updated_at=updated_at,
|
created_at=created_at, updated_at=updated_at,
|
||||||
description=description, clock_id=clock_id, tags=tags or [],
|
description=description, clock_id=clock_id, tags=tags or [],
|
||||||
led_count=led_count, fallback_color=fb,
|
fallback_color=fb,
|
||||||
timeout=float(timeout) if timeout is not None else 5.0,
|
timeout=float(timeout) if timeout is not None else 5.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -827,8 +825,6 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
self.fallback_color = fallback_color
|
self.fallback_color = fallback_color
|
||||||
if kwargs.get("timeout") is not None:
|
if kwargs.get("timeout") is not None:
|
||||||
self.timeout = float(kwargs["timeout"])
|
self.timeout = float(kwargs["timeout"])
|
||||||
if kwargs.get("led_count") is not None:
|
|
||||||
self.led_count = kwargs["led_count"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
|
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
|
||||||
<div class="modal-content" style="max-width: 450px;">
|
<div class="modal-content" style="max-width: 480px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
|
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
|
||||||
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="settings-tab-bar">
|
||||||
|
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
|
||||||
|
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
|
||||||
|
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<!-- ═══ General tab ═══ -->
|
||||||
|
<div id="settings-panel-general" class="settings-panel active">
|
||||||
<!-- API Keys section (read-only) -->
|
<!-- API Keys section (read-only) -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
@@ -16,6 +26,41 @@
|
|||||||
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Level section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.log_level.label">Log Level</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
|
||||||
|
<select id="settings-log-level">
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
<option value="INFO">INFO</option>
|
||||||
|
<option value="WARNING">WARNING</option>
|
||||||
|
<option value="ERROR">ERROR</option>
|
||||||
|
<option value="CRITICAL">CRITICAL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Logs button (opens overlay) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.logs.label">Server Logs</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" onclick="openLogOverlay()" style="width:100%" data-i18n="settings.logs.open_viewer">Open Log Viewer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.restart_server">Restart Server</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Backup tab ═══ -->
|
||||||
|
<div id="settings-panel-backup" class="settings-panel">
|
||||||
<!-- Backup section -->
|
<!-- Backup section -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
@@ -119,8 +164,10 @@
|
|||||||
<small class="input-hint" style="display:none" data-i18n="settings.saved_backups.hint">Auto-backup files stored on the server. Download to save locally, or delete to free space.</small>
|
<small class="input-hint" style="display:none" data-i18n="settings.saved_backups.hint">Auto-backup files stored on the server. Download to save locally, or delete to free space.</small>
|
||||||
<div id="saved-backups-list"></div>
|
<div id="saved-backups-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- MQTT section -->
|
<!-- ═══ MQTT tab ═══ -->
|
||||||
|
<div id="settings-panel-mqtt" class="settings-panel">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label data-i18n="settings.mqtt.label">MQTT</label>
|
<label data-i18n="settings.mqtt.label">MQTT</label>
|
||||||
@@ -169,51 +216,6 @@
|
|||||||
|
|
||||||
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server Logs section -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label data-i18n="settings.logs.label">Server Logs</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="settings.logs.hint">Stream live server log output. Use the filter to show only relevant log levels.</small>
|
|
||||||
|
|
||||||
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
|
|
||||||
<button id="log-viewer-connect-btn" class="btn btn-secondary" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
|
|
||||||
<button class="btn btn-secondary" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
|
|
||||||
<select id="log-viewer-filter" onchange="applyLogFilter()" style="flex:1; font-size:0.85rem;">
|
|
||||||
<option value="all" data-i18n="settings.logs.filter.all">All</option>
|
|
||||||
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
|
|
||||||
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
|
|
||||||
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<pre id="log-viewer-output" class="log-viewer-output"></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Log Level section -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label data-i18n="settings.log_level.label">Log Level</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
|
|
||||||
<select id="settings-log-level">
|
|
||||||
<option value="DEBUG">DEBUG</option>
|
|
||||||
<option value="INFO">INFO</option>
|
|
||||||
<option value="WARNING">WARNING</option>
|
|
||||||
<option value="ERROR">ERROR</option>
|
|
||||||
<option value="CRITICAL">CRITICAL</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Restart section -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label data-i18n="settings.restart_server">Restart Server</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="settings-error" class="error-message" style="display:none;"></div>
|
<div id="settings-error" class="error-message" style="display:none;"></div>
|
||||||
@@ -223,3 +225,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Viewer Overlay (full-screen, independent of settings modal) -->
|
||||||
|
<div id="log-overlay" class="log-overlay" style="display:none;">
|
||||||
|
<button class="log-overlay-close" onclick="closeLogOverlay()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
<div class="log-overlay-toolbar">
|
||||||
|
<h3 data-i18n="settings.logs.label">Server Logs</h3>
|
||||||
|
<select id="log-viewer-filter" onchange="applyLogFilter()">
|
||||||
|
<option value="all" data-i18n="settings.logs.filter.all">All levels</option>
|
||||||
|
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
|
||||||
|
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
|
||||||
|
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
|
||||||
|
</select>
|
||||||
|
<button id="log-viewer-connect-btn" class="btn btn-secondary btn-sm" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
|
||||||
|
</div>
|
||||||
|
<pre id="log-viewer-output" class="log-viewer-output"></pre>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED count & FPS controls -->
|
<!-- LED count & FPS controls -->
|
||||||
<div class="css-test-led-control">
|
<div id="css-test-led-fps-group" class="css-test-led-control">
|
||||||
<span id="css-test-led-group">
|
<span id="css-test-led-group">
|
||||||
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
|
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
|
||||||
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
|
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
|
||||||
@@ -62,6 +62,17 @@
|
|||||||
<button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">✓</button>
|
<button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">✓</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- FPS chart (for api_input sources) — matches target card sparkline -->
|
||||||
|
<div id="css-test-fps-chart-group" class="target-fps-row" style="display:none">
|
||||||
|
<div class="target-fps-sparkline">
|
||||||
|
<canvas id="css-test-fps-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="target-fps-label">
|
||||||
|
<span id="css-test-fps-value" class="metric-value">0</span>
|
||||||
|
<span class="target-fps-avg" id="css-test-fps-avg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
|
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user