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
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
Platform.SENSOR,
|
||||
Platform.NUMBER,
|
||||
@@ -148,6 +151,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
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)
|
||||
|
||||
return True
|
||||
|
||||
@@ -336,6 +336,38 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.warning("Failed to fetch scene presets: %s", err)
|
||||
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:
|
||||
"""Activate a scene preset."""
|
||||
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}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
@@ -66,5 +71,21 @@
|
||||
"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.
|
||||
|
||||
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:
|
||||
source = store.get_source(source_id)
|
||||
@@ -420,20 +421,32 @@ async def push_colors(
|
||||
if not isinstance(source, ApiInputColorStripSource):
|
||||
raise HTTPException(status_code=400, detail="Source is not an api_input type")
|
||||
|
||||
colors_array = np.array(body.colors, dtype=np.uint8)
|
||||
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")
|
||||
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
"leds_received": len(body.colors),
|
||||
}
|
||||
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)
|
||||
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")
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
"leds_received": len(body.colors),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"])
|
||||
@@ -708,19 +721,42 @@ async def css_api_input_ws(
|
||||
break
|
||||
|
||||
if "text" in message:
|
||||
# JSON frame: {"colors": [[R,G,B], ...]}
|
||||
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
|
||||
import json
|
||||
try:
|
||||
data = json.loads(message["text"])
|
||||
raw_colors = data.get("colors", [])
|
||||
colors_array = np.array(raw_colors, dtype=np.uint8)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
||||
continue
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
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)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
||||
continue
|
||||
except (ValueError, TypeError) as e:
|
||||
await websocket.send_json({"error": str(e)})
|
||||
continue
|
||||
else:
|
||||
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
|
||||
continue
|
||||
|
||||
elif "bytes" in message:
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||
raw_bytes = message["bytes"]
|
||||
@@ -732,7 +768,7 @@ async def css_api_input_ws(
|
||||
else:
|
||||
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)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
@@ -799,6 +835,10 @@ async def test_color_strip_ws(
|
||||
try:
|
||||
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
|
||||
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
|
||||
is_composite = isinstance(source, CompositeColorStripSource)
|
||||
@@ -875,9 +915,18 @@ async def test_color_strip_ws(
|
||||
elif composite_colors is not None:
|
||||
await websocket.send_bytes(composite_colors.tobytes())
|
||||
else:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
# 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:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
|
||||
# Periodically send auxiliary data (frame preview, brightness)
|
||||
now = _time.monotonic()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
|
||||
count: int = Field(description="Number of sources")
|
||||
|
||||
|
||||
class ColorPushRequest(BaseModel):
|
||||
"""Request to push raw LED colors to an api_input source."""
|
||||
class SegmentPayload(BaseModel):
|
||||
"""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):
|
||||
|
||||
@@ -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
|
||||
received within `timeout` seconds, LEDs revert to `fallback_color`.
|
||||
|
||||
Thread-safe: push_colors() can be called from any thread (REST handler,
|
||||
WebSocket handler) while get_latest_colors() is called from the target
|
||||
processor thread.
|
||||
Thread-safe: push_colors() / push_segments() can be called from any thread
|
||||
(REST handler, WebSocket handler) while get_latest_colors() is called from
|
||||
the target processor thread.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_DEFAULT_LED_COUNT = 150
|
||||
|
||||
|
||||
class ApiInputColorStripStream(ColorStripStream):
|
||||
"""Color strip stream backed by externally-pushed LED color data.
|
||||
|
||||
Holds a thread-safe np.ndarray buffer. External clients push colors via
|
||||
push_colors(). A background thread checks for timeout and reverts to
|
||||
fallback_color when no data arrives within the configured timeout window.
|
||||
push_colors() or push_segments(). A background thread checks for timeout
|
||||
and reverts to fallback_color when no data arrives within the configured
|
||||
timeout window.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
fallback = source.fallback_color
|
||||
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._auto_size = not source.led_count
|
||||
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||
self._led_count = _DEFAULT_LED_COUNT
|
||||
|
||||
# Build initial fallback buffer
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
self._colors = self._fallback_array.copy()
|
||||
self._last_push_time: float = 0.0
|
||||
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:
|
||||
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
|
||||
@@ -59,40 +62,124 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
(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:
|
||||
"""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:
|
||||
colors: np.ndarray shape (N, 3) uint8
|
||||
"""
|
||||
with self._lock:
|
||||
n = len(colors)
|
||||
# Auto-grow if incoming data is larger
|
||||
if n > self._led_count:
|
||||
self._ensure_capacity(n)
|
||||
if n == self._led_count:
|
||||
self._colors = colors.astype(np.uint8)
|
||||
elif n > self._led_count:
|
||||
self._colors = colors[:self._led_count].astype(np.uint8)
|
||||
else:
|
||||
elif n < self._led_count:
|
||||
# Zero-pad to led_count
|
||||
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||
padded[:n] = colors[:n]
|
||||
self._colors = padded
|
||||
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
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
"""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:
|
||||
self._led_count = device_led_count
|
||||
self._fallback_array = self._build_fallback(device_led_count)
|
||||
self._colors = self._fallback_array.copy()
|
||||
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
|
||||
def target_fps(self) -> int:
|
||||
@@ -131,6 +218,11 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
with self._lock:
|
||||
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:
|
||||
"""Hot-update fallback_color and timeout from updated source config."""
|
||||
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
||||
@@ -138,19 +230,10 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
fallback = source.fallback_color
|
||||
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)
|
||||
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:
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
if self._timed_out:
|
||||
self._colors = self._fallback_array.copy()
|
||||
logger.info("ApiInputColorStripStream params updated in-place")
|
||||
|
||||
def _timeout_loop(self) -> None:
|
||||
|
||||
@@ -269,6 +269,8 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
|
||||
|
||||
/* Composite layers preview */
|
||||
.css-test-layers {
|
||||
display: flex;
|
||||
@@ -346,7 +348,107 @@
|
||||
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 {
|
||||
background: #0d0d0d;
|
||||
|
||||
@@ -182,12 +182,14 @@ import { switchTab, initTabs, startAutoRefresh, handlePopState } from './feature
|
||||
import { navigateToCard } from './core/navigation.js';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||
downloadBackup, handleRestoreFileSelected,
|
||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
restartServer, saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport, handlePartialImportFileSelected,
|
||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||
openLogOverlay, closeLogOverlay,
|
||||
loadLogLevel, setLogLevel,
|
||||
} from './features/settings.js';
|
||||
|
||||
@@ -522,9 +524,10 @@ Object.assign(window, {
|
||||
openCommandPalette,
|
||||
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,
|
||||
closeSettingsModal,
|
||||
switchSettingsTab,
|
||||
downloadBackup,
|
||||
handleRestoreFileSelected,
|
||||
saveAutoBackupSettings,
|
||||
@@ -540,6 +543,8 @@ Object.assign(window, {
|
||||
disconnectLogViewer,
|
||||
clearLogViewer,
|
||||
applyLogFilter,
|
||||
openLogOverlay,
|
||||
closeLogOverlay,
|
||||
loadLogLevel,
|
||||
setLogLevel,
|
||||
});
|
||||
@@ -569,8 +574,11 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// Close in order: overlay lightboxes first, then modals via stack
|
||||
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
// Close in order: log overlay > overlay lightboxes > modals via stack
|
||||
const logOverlay = document.getElementById('log-overlay');
|
||||
if (logOverlay && logOverlay.style.display !== 'none') {
|
||||
closeLogOverlay();
|
||||
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
closeDisplayPicker();
|
||||
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
||||
closeLightbox();
|
||||
|
||||
@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
|
||||
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) {
|
||||
const cache = ENTITY_CACHE_MAP[entityType];
|
||||
if (cache) {
|
||||
cache.fetch({ force: true });
|
||||
}
|
||||
if (!cache) return;
|
||||
|
||||
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', {
|
||||
detail: { entity_type: entityType },
|
||||
}));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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 { t } from '../core/i18n.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');
|
||||
audioSourceModal.forceClose();
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
@@ -205,6 +206,7 @@ export async function deleteAudioSource(sourceId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('audio_source.deleted'), 'success');
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -706,6 +706,7 @@ export async function saveAutomationEditor() {
|
||||
|
||||
automationModal.forceClose();
|
||||
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -720,6 +721,7 @@ export async function toggleAutomationEnabled(automationId, enable) {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -767,6 +769,7 @@ export async function deleteAutomation(automationId, automationName) {
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to delete automation');
|
||||
showToast(t('automations.deleted'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
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 { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
@@ -198,8 +199,8 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
_syncAnimationSpeedState();
|
||||
|
||||
// LED count — only shown for picture, picture_advanced, api_input
|
||||
const hasLedCount = ['picture', 'picture_advanced', 'api_input'];
|
||||
// LED count — only shown for picture, picture_advanced
|
||||
const hasLedCount = ['picture', 'picture_advanced'];
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
@@ -1656,9 +1657,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const notifHistoryBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = !isApiInput
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-css-id',
|
||||
@@ -2259,6 +2258,7 @@ export async function saveCSSEditor() {
|
||||
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
cssEditorModal.forceClose();
|
||||
if (window.loadPictureSources) window.loadPictureSources();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -2335,6 +2335,7 @@ export async function deleteColorStrip(cssId) {
|
||||
if (response.ok) {
|
||||
showToast(t('color_strip.deleted'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
if (window.loadPictureSources) window.loadPictureSources();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} else {
|
||||
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 _cssTestCSPTMode = false; // true when testing a CSPT template
|
||||
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;
|
||||
|
||||
function _getCssTestLedCount() {
|
||||
@@ -2488,12 +2494,32 @@ function _getCssTestFps() {
|
||||
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) {
|
||||
_cssTestCSPTMode = false;
|
||||
_cssTestCSPTId = null;
|
||||
// Hide CSPT input selector
|
||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||
if (csptGroup) csptGroup.style.display = 'none';
|
||||
// Detect api_input type
|
||||
const sources = colorStripSourcesCache.data || [];
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2503,25 +2529,9 @@ export async function testCSPT(templateId) {
|
||||
|
||||
// Populate input source selector
|
||||
await colorStripSourcesCache.fetch();
|
||||
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}">${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 = '';
|
||||
_populateCssTestSourceSelector(null);
|
||||
|
||||
const sel = document.getElementById('css-test-cspt-input-select');
|
||||
const inputId = sel.value;
|
||||
if (!inputId) {
|
||||
showToast(t('color_strip.processed.error.no_input'), 'error');
|
||||
@@ -2550,23 +2560,42 @@ function _openTestModal(sourceId) {
|
||||
document.getElementById('css-test-rect-view').style.display = 'none';
|
||||
document.getElementById('css-test-layers-view').style.display = 'none';
|
||||
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');
|
||||
if (layersContainer) layersContainer.innerHTML = '';
|
||||
document.getElementById('css-test-status').style.display = '';
|
||||
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
|
||||
|
||||
// Restore LED count + FPS + Enter key handlers
|
||||
const ledCount = _getCssTestLedCount();
|
||||
const ledInput = document.getElementById('css-test-led-input');
|
||||
ledInput.value = ledCount;
|
||||
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
// Reset FPS tracking
|
||||
_cssTestFpsHistory = [];
|
||||
|
||||
const fpsVal = _getCssTestFps();
|
||||
const fpsInput = document.getElementById('css-test-fps-input');
|
||||
fpsInput.value = fpsVal;
|
||||
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
// 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
|
||||
const ledCount = _getCssTestLedCount();
|
||||
const ledInput = document.getElementById('css-test-led-input');
|
||||
ledInput.value = ledCount;
|
||||
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
|
||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||
const fpsVal = _getCssTestFps();
|
||||
const fpsInput = document.getElementById('css-test-fps-input');
|
||||
fpsInput.value = fpsVal;
|
||||
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
|
||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||
}
|
||||
}
|
||||
|
||||
function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
@@ -2714,6 +2743,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
// Standard format: raw RGB
|
||||
_cssTestLatestRgb = raw;
|
||||
}
|
||||
|
||||
// Track FPS for api_input sources
|
||||
if (_cssTestIsApiInput) {
|
||||
_cssTestFpsTimestamps.push(performance.now());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2805,12 +2839,14 @@ export function applyCssTestSettings() {
|
||||
_cssTestMeta = null;
|
||||
_cssTestLayerData = null;
|
||||
|
||||
// In CSPT mode, read selected input source
|
||||
if (_cssTestCSPTMode) {
|
||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||
if (inputSel && inputSel.value) {
|
||||
_cssTestSourceId = inputSel.value;
|
||||
}
|
||||
// Read selected input source from selector (both CSS and CSPT modes)
|
||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||
if (inputSel && 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)
|
||||
@@ -3162,6 +3198,60 @@ export function fireCssTestNotificationLayer(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() {
|
||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
||||
@@ -3171,6 +3261,10 @@ export function closeTestCssSourceModal() {
|
||||
_cssTestIsComposite = false;
|
||||
_cssTestLayerData = null;
|
||||
_cssTestNotificationIds = [];
|
||||
_cssTestIsApiInput = false;
|
||||
_cssTestStopFpsSampling();
|
||||
_cssTestFpsTimestamps = [];
|
||||
_cssTestFpsActualHistory = [];
|
||||
// Revoke blob URL for frame preview
|
||||
const screen = document.getElementById('css-test-rect-screen');
|
||||
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }
|
||||
|
||||
@@ -243,6 +243,7 @@ export async function saveScenePreset() {
|
||||
|
||||
scenePresetModal.forceClose();
|
||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -348,6 +349,7 @@ export async function recaptureScenePreset(presetId) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||
@@ -420,6 +422,7 @@ export async function deleteScenePreset(presetId) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
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';
|
||||
@@ -10,6 +10,17 @@ import { t } from '../core/i18n.js';
|
||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.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 ────────────────────────────────────────────
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
@@ -114,9 +125,6 @@ export function clearLogViewer() {
|
||||
|
||||
/** Re-render the log output according to the current filter selection. */
|
||||
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');
|
||||
if (!output) return;
|
||||
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)
|
||||
const settingsModal = new Modal('settings-modal');
|
||||
|
||||
let _logFilterIconSelect = null;
|
||||
let _logLevelIconSelect = null;
|
||||
|
||||
const _LOG_LEVEL_ITEMS = [
|
||||
{ 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: '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: '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') },
|
||||
];
|
||||
/** 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: '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: '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') },
|
||||
];
|
||||
}
|
||||
|
||||
export function openSettingsModal() {
|
||||
document.getElementById('settings-error').style.display = 'none';
|
||||
|
||||
// Reset to first tab
|
||||
switchSettingsTab('general');
|
||||
|
||||
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
|
||||
if (!_logLevelIconSelect) {
|
||||
const levelSel = document.getElementById('settings-log-level');
|
||||
if (levelSel) {
|
||||
_logLevelIconSelect = new IconSelect({
|
||||
target: levelSel,
|
||||
items: _LOG_LEVEL_ITEMS,
|
||||
items: _getLogLevelItems(),
|
||||
columns: 3,
|
||||
onChange: () => setLogLevel(),
|
||||
});
|
||||
@@ -186,7 +228,6 @@ export function openSettingsModal() {
|
||||
}
|
||||
|
||||
export function closeSettingsModal() {
|
||||
disconnectLogViewer();
|
||||
settingsModal.forceClose();
|
||||
}
|
||||
|
||||
|
||||
@@ -710,6 +710,7 @@ export async function saveTemplate() {
|
||||
|
||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||
templateModal.forceClose();
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('templates.deleted'), 'success');
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
} catch (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');
|
||||
audioTemplateModal.forceClose();
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('audio_template.deleted'), 'success');
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
} catch (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');
|
||||
streamModal.forceClose();
|
||||
streamsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('streams.deleted'), 'success');
|
||||
streamsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (error) {
|
||||
console.error('Error deleting stream:', error);
|
||||
@@ -2675,6 +2681,7 @@ export async function savePPTemplate() {
|
||||
|
||||
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||||
ppTemplateModal.forceClose();
|
||||
ppTemplatesCache.invalidate();
|
||||
await loadPPTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('postprocessing.deleted'), 'success');
|
||||
ppTemplatesCache.invalidate();
|
||||
await loadPPTemplates();
|
||||
} catch (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');
|
||||
csptModal.forceClose();
|
||||
csptCache.invalidate();
|
||||
await loadCSPTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('css_processing.deleted'), 'success');
|
||||
csptCache.invalidate();
|
||||
await loadCSPTemplates();
|
||||
} catch (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');
|
||||
syncClockModal.forceClose();
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -143,6 +144,7 @@ export async function deleteSyncClock(clockId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('sync_clock.deleted'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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 { t } from '../core/i18n.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');
|
||||
valueSourceModal.forceClose();
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
@@ -536,6 +537,7 @@ export async function deleteValueSource(sourceId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('value_source.deleted'), 'success');
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -312,6 +312,10 @@
|
||||
"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",
|
||||
"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.capture.title": "Capture Settings",
|
||||
"settings.capture.saved": "Capture settings updated",
|
||||
@@ -1076,6 +1080,7 @@
|
||||
"color_strip.test.error": "Failed to connect to preview stream",
|
||||
"color_strip.test.led_count": "LEDs:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "Receive FPS",
|
||||
"color_strip.test.apply": "Apply",
|
||||
"color_strip.test.composite": "Composite",
|
||||
"color_strip.preview.title": "Live Preview",
|
||||
|
||||
@@ -312,6 +312,10 @@
|
||||
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
|
||||
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
|
||||
"settings.title": "Настройки",
|
||||
"settings.tab.general": "Основные",
|
||||
"settings.tab.backup": "Бэкап",
|
||||
"settings.tab.mqtt": "MQTT",
|
||||
"settings.logs.open_viewer": "Открыть логи",
|
||||
"settings.general.title": "Основные Настройки",
|
||||
"settings.capture.title": "Настройки Захвата",
|
||||
"settings.capture.saved": "Настройки захвата обновлены",
|
||||
@@ -1076,6 +1080,7 @@
|
||||
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
||||
"color_strip.test.led_count": "Кол-во LED:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "Частота приёма",
|
||||
"color_strip.test.apply": "Применить",
|
||||
"color_strip.test.composite": "Композит",
|
||||
"color_strip.preview.title": "Предпросмотр",
|
||||
|
||||
@@ -312,6 +312,10 @@
|
||||
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
|
||||
"device.tip.add": "点击此处添加新的 LED 设备",
|
||||
"settings.title": "设置",
|
||||
"settings.tab.general": "常规",
|
||||
"settings.tab.backup": "备份",
|
||||
"settings.tab.mqtt": "MQTT",
|
||||
"settings.logs.open_viewer": "打开日志查看器",
|
||||
"settings.general.title": "常规设置",
|
||||
"settings.capture.title": "采集设置",
|
||||
"settings.capture.saved": "采集设置已更新",
|
||||
@@ -1076,6 +1080,7 @@
|
||||
"color_strip.test.error": "无法连接到预览流",
|
||||
"color_strip.test.led_count": "LED数量:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "接收帧率",
|
||||
"color_strip.test.apply": "应用",
|
||||
"color_strip.test.composite": "合成",
|
||||
"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
|
||||
buffers the latest frame and serves it to targets. When no data has been
|
||||
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]
|
||||
timeout: float = 5.0 # seconds before reverting to fallback
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["led_count"] = self.led_count
|
||||
d["fallback_color"] = list(self.fallback_color)
|
||||
d["timeout"] = self.timeout
|
||||
return d
|
||||
@@ -810,14 +808,14 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
led_count=0, fallback_color=None, timeout=None,
|
||||
fallback_color=None, timeout=None,
|
||||
**_kwargs):
|
||||
fb = _validate_rgb(fallback_color, [0, 0, 0])
|
||||
return cls(
|
||||
id=id, name=name, source_type="api_input",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -827,8 +825,6 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
self.fallback_color = fallback_color
|
||||
if kwargs.get("timeout") is not None:
|
||||
self.timeout = float(kwargs["timeout"])
|
||||
if kwargs.get("led_count") is not None:
|
||||
self.led_count = kwargs["led_count"]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,219 +1,221 @@
|
||||
<!-- Settings Modal -->
|
||||
<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">
|
||||
<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>
|
||||
</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">
|
||||
<!-- API Keys section (read-only) -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.api_keys.label">API Keys</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
<!-- ═══ General tab ═══ -->
|
||||
<div id="settings-panel-general" class="settings-panel active">
|
||||
<!-- API Keys section (read-only) -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.api_keys.label">API Keys</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
|
||||
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
|
||||
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.backup.label">Backup Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
|
||||
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
|
||||
</div>
|
||||
|
||||
<!-- Restore section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.restore.label">Restore Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
|
||||
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
|
||||
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
|
||||
</div>
|
||||
|
||||
<!-- Partial Export/Import section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.partial.label">Partial Export / Import</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<select id="settings-partial-store" style="flex:1">
|
||||
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
|
||||
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
|
||||
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
|
||||
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
|
||||
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
|
||||
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
|
||||
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
|
||||
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
|
||||
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
|
||||
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
|
||||
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
|
||||
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
|
||||
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
|
||||
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
|
||||
<!-- 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>
|
||||
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="settings-partial-merge">
|
||||
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
|
||||
<!-- 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>
|
||||
|
||||
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Auto-Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="auto-backup-enabled">
|
||||
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
|
||||
<!-- ═══ Backup tab ═══ -->
|
||||
<div id="settings-panel-backup" class="settings-panel">
|
||||
<!-- Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.backup.label">Backup Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
|
||||
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
|
||||
<select id="auto-backup-interval" style="width:100%">
|
||||
<option value="1">1h</option>
|
||||
<option value="6">6h</option>
|
||||
<option value="12">12h</option>
|
||||
<option value="24">24h</option>
|
||||
<option value="48">48h</option>
|
||||
<option value="168">7d</option>
|
||||
<!-- Restore section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.restore.label">Restore Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
|
||||
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
|
||||
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
|
||||
</div>
|
||||
|
||||
<!-- Partial Export/Import section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.partial.label">Partial Export / Import</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<select id="settings-partial-store" style="flex:1">
|
||||
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
|
||||
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
|
||||
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
|
||||
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
|
||||
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
|
||||
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
|
||||
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
|
||||
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
|
||||
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
|
||||
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
|
||||
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
|
||||
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
|
||||
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
|
||||
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
|
||||
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="settings-partial-merge">
|
||||
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
|
||||
</div>
|
||||
|
||||
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="width:100%" data-i18n="settings.auto_backup.save">Save Settings</button>
|
||||
<!-- Auto-Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
|
||||
|
||||
<div id="auto-backup-status" style="font-size:0.85rem; color:var(--text-muted); margin-top:0.5rem;"></div>
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="auto-backup-enabled">
|
||||
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
|
||||
<select id="auto-backup-interval" style="width:100%">
|
||||
<option value="1">1h</option>
|
||||
<option value="6">6h</option>
|
||||
<option value="12">12h</option>
|
||||
<option value="24">24h</option>
|
||||
<option value="48">48h</option>
|
||||
<option value="168">7d</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
|
||||
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="width:100%" data-i18n="settings.auto_backup.save">Save Settings</button>
|
||||
|
||||
<div id="auto-backup-status" style="font-size:0.85rem; color:var(--text-muted); margin-top:0.5rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Backups section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.saved_backups.label">Saved Backups</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Saved Backups section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.saved_backups.label">Saved Backups</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- MQTT section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.mqtt.label">MQTT</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<input type="checkbox" id="mqtt-enabled">
|
||||
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
|
||||
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
|
||||
<!-- ═══ MQTT tab ═══ -->
|
||||
<div id="settings-panel-mqtt" class="settings-panel">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.mqtt.label">MQTT</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<div style="width:90px">
|
||||
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
|
||||
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<input type="checkbox" id="mqtt-enabled">
|
||||
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
|
||||
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
|
||||
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
|
||||
</div>
|
||||
<div style="width:90px">
|
||||
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
|
||||
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
|
||||
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
|
||||
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
|
||||
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
|
||||
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
|
||||
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
|
||||
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
|
||||
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
|
||||
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
|
||||
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
||||
</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 id="settings-error" class="error-message" style="display:none;"></div>
|
||||
@@ -223,3 +225,20 @@
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
@@ -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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user