Compare commits
2 Commits
ebec1bd16e
...
4c21ae5178
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c21ae5178 | |||
| d229c9a0d5 |
@@ -1,46 +1,55 @@
|
|||||||
"""DDP (Distributed Display Protocol) client for WLED."""
|
"""DDP (Distributed Display Protocol) client for WLED."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import struct
|
import struct
|
||||||
from typing import List, Tuple
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# DDP color order codes (matches WLED's order enum)
|
||||||
|
COLOR_ORDER_MAP: Dict[int, Tuple[int, int, int]] = {
|
||||||
|
0: (1, 0, 2), # GRB → indices into (R,G,B)
|
||||||
|
1: (0, 1, 2), # RGB → no reorder
|
||||||
|
2: (2, 0, 1), # BRG
|
||||||
|
3: (0, 2, 1), # RBG
|
||||||
|
4: (2, 1, 0), # BGR
|
||||||
|
5: (1, 2, 0), # GBR
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BusConfig:
|
||||||
|
"""Physical LED bus/output configuration from WLED."""
|
||||||
|
start: int # First LED index
|
||||||
|
length: int # Number of LEDs on this bus
|
||||||
|
color_order: int # Color order code (0=GRB, 1=RGB, etc.)
|
||||||
|
|
||||||
|
|
||||||
class DDPClient:
|
class DDPClient:
|
||||||
"""UDP DDP client for sending pixel data to WLED devices."""
|
"""UDP DDP client for sending pixel data to WLED devices."""
|
||||||
|
|
||||||
DDP_PORT = 4048
|
DDP_PORT = 4048
|
||||||
DDP_FLAGS_VER1 = 0x40 # VER=1, TIMECODE=0, STORAGE=0, REPLY=0, QUERY=0, PUSH=1
|
DDP_FLAGS_VER1 = 0x40 # VER=1, all other flags 0
|
||||||
|
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
|
||||||
DDP_TYPE_RGB = 0x01
|
DDP_TYPE_RGB = 0x01
|
||||||
|
|
||||||
# Color order mappings: input (R,G,B) -> output indices
|
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
|
||||||
COLOR_ORDER_MAP = {
|
|
||||||
0: (1, 0, 2), # GRB: G=idx1, R=idx0, B=idx2
|
|
||||||
1: (0, 1, 2), # RGB: R=idx0, G=idx1, B=idx2
|
|
||||||
2: (2, 0, 1), # BRG: B=idx2, R=idx0, G=idx1
|
|
||||||
3: (0, 2, 1), # RBG: R=idx0, B=idx2, G=idx1
|
|
||||||
4: (2, 1, 0), # BGR: B=idx2, G=idx1, R=idx0
|
|
||||||
5: (1, 2, 0), # GBR: G=idx1, B=idx2, R=idx0
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False, color_order: int = 1):
|
|
||||||
"""Initialize DDP client.
|
"""Initialize DDP client.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host: WLED device IP address or hostname
|
host: WLED device IP address or hostname
|
||||||
port: DDP port (default 4048)
|
port: DDP port (default 4048)
|
||||||
rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED)
|
rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED)
|
||||||
color_order: Color order (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR)
|
|
||||||
"""
|
"""
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.rgbw = rgbw
|
self.rgbw = rgbw
|
||||||
self.color_order = color_order
|
|
||||||
self._transport = None
|
self._transport = None
|
||||||
self._protocol = None
|
self._protocol = None
|
||||||
self._sequence = 0
|
self._sequence = 0
|
||||||
|
self._buses: List[BusConfig] = []
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Establish UDP connection."""
|
"""Establish UDP connection."""
|
||||||
@@ -64,32 +73,50 @@ class DDPClient:
|
|||||||
self._protocol = None
|
self._protocol = None
|
||||||
logger.debug(f"Closed DDP connection to {self.host}:{self.port}")
|
logger.debug(f"Closed DDP connection to {self.host}:{self.port}")
|
||||||
|
|
||||||
|
def set_buses(self, buses: List[BusConfig]) -> None:
|
||||||
|
"""Set WLED bus configurations for per-bus color order reordering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buses: List of BusConfig from WLED device
|
||||||
|
"""
|
||||||
|
self._buses = buses
|
||||||
|
for bus in buses:
|
||||||
|
order_name = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
|
||||||
|
logger.info(
|
||||||
|
f"DDP bus: LEDs {bus.start}-{bus.start + bus.length - 1}, "
|
||||||
|
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
|
||||||
|
)
|
||||||
|
|
||||||
def _build_ddp_packet(
|
def _build_ddp_packet(
|
||||||
self,
|
self,
|
||||||
rgb_data: bytes,
|
rgb_data: bytes,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
sequence: int = 1
|
sequence: int = 1,
|
||||||
|
push: bool = False,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Build a DDP packet.
|
"""Build a DDP packet.
|
||||||
|
|
||||||
DDP packet format (10-byte header + data):
|
DDP packet format (10-byte header + data):
|
||||||
- Byte 0: Flags (0x40 = VER1, PUSH)
|
- Byte 0: Flags (VER1 | PUSH on last packet)
|
||||||
- Byte 1: Sequence number
|
- Byte 1: Sequence number
|
||||||
- Byte 2: Data type (0x01 = RGB)
|
- Byte 2: Data type (0x01 = RGB)
|
||||||
- Byte 3: Source/Destination ID
|
- Byte 3: Source/Destination ID
|
||||||
- Bytes 4-7: Data offset in bytes (4-byte little-endian)
|
- Bytes 4-7: Data offset (4 bytes, big-endian)
|
||||||
- Bytes 8-9: Data length in bytes (2-byte big-endian)
|
- Bytes 8-9: Data length (2 bytes, big-endian)
|
||||||
- Bytes 10+: Pixel data
|
- Bytes 10+: Pixel data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rgb_data: RGB pixel data as bytes
|
rgb_data: RGB pixel data as bytes
|
||||||
offset: Byte offset (pixel_index * 3)
|
offset: Byte offset (pixel_index * 3)
|
||||||
sequence: Sequence number (0-255)
|
sequence: Sequence number (0-255)
|
||||||
|
push: True for the last packet of a frame
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete DDP packet as bytes
|
Complete DDP packet as bytes
|
||||||
"""
|
"""
|
||||||
flags = self.DDP_FLAGS_VER1
|
flags = self.DDP_FLAGS_VER1
|
||||||
|
if push:
|
||||||
|
flags |= self.DDP_FLAGS_PUSH
|
||||||
data_type = self.DDP_TYPE_RGB
|
data_type = self.DDP_TYPE_RGB
|
||||||
source_id = 0x01
|
source_id = 0x01
|
||||||
data_len = len(rgb_data)
|
data_len = len(rgb_data)
|
||||||
@@ -107,6 +134,40 @@ class DDPClient:
|
|||||||
|
|
||||||
return header + rgb_data
|
return header + rgb_data
|
||||||
|
|
||||||
|
def _reorder_pixels(
|
||||||
|
self,
|
||||||
|
pixels: List[Tuple[int, int, int]],
|
||||||
|
) -> List[Tuple[int, int, int]]:
|
||||||
|
"""Apply per-bus color order reordering.
|
||||||
|
|
||||||
|
WLED may not apply per-bus color order conversion for DDP data on
|
||||||
|
all buses (observed in multi-bus setups). We reorder pixel channels
|
||||||
|
here so the hardware receives the correct byte order directly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pixels: List of (R, G, B) tuples in standard RGB order
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of reordered tuples matching each bus's hardware color order
|
||||||
|
"""
|
||||||
|
if not self._buses:
|
||||||
|
return pixels
|
||||||
|
|
||||||
|
result = list(pixels)
|
||||||
|
for bus in self._buses:
|
||||||
|
order_map = COLOR_ORDER_MAP.get(bus.color_order)
|
||||||
|
if not order_map or order_map == (0, 1, 2):
|
||||||
|
continue # RGB order = no reordering needed
|
||||||
|
|
||||||
|
start = bus.start
|
||||||
|
end = min(bus.start + bus.length, len(result))
|
||||||
|
for i in range(start, end):
|
||||||
|
r, g, b = result[i]
|
||||||
|
rgb = (r, g, b)
|
||||||
|
result[i] = (rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def send_pixels(
|
async def send_pixels(
|
||||||
self,
|
self,
|
||||||
pixels: List[Tuple[int, int, int]],
|
pixels: List[Tuple[int, int, int]],
|
||||||
@@ -128,21 +189,20 @@ class DDPClient:
|
|||||||
raise RuntimeError("DDP client not connected")
|
raise RuntimeError("DDP client not connected")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get color order mapping
|
# Send plain RGB — WLED handles per-bus color order conversion
|
||||||
order_map = self.COLOR_ORDER_MAP.get(self.color_order, (0, 1, 2))
|
# internally when outputting to hardware.
|
||||||
|
bpp = 4 if self.rgbw else 3 # bytes per pixel
|
||||||
# Convert pixels to flat RGB or RGBW bytes with color reordering
|
|
||||||
pixel_bytes = bytearray()
|
pixel_bytes = bytearray()
|
||||||
for r, g, b in pixels:
|
for r, g, b in pixels:
|
||||||
# Reorder color channels based on WLED configuration
|
pixel_bytes.extend((int(r), int(g), int(b)))
|
||||||
rgb = [int(r), int(g), int(b)]
|
|
||||||
reordered = [rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]]]
|
|
||||||
pixel_bytes.extend(reordered)
|
|
||||||
if self.rgbw:
|
if self.rgbw:
|
||||||
pixel_bytes.append(0) # White channel = 0
|
pixel_bytes.append(0) # White channel = 0
|
||||||
|
|
||||||
total_bytes = len(pixel_bytes)
|
total_bytes = len(pixel_bytes)
|
||||||
bytes_per_packet = max_packet_size - 10 # Account for 10-byte header
|
# Align payload to full pixels (multiple of bpp) to avoid splitting
|
||||||
|
# a pixel's channels across packets
|
||||||
|
max_payload = max_packet_size - 10 # 10-byte header
|
||||||
|
bytes_per_packet = (max_payload // bpp) * bpp
|
||||||
|
|
||||||
# Split into multiple packets if needed
|
# Split into multiple packets if needed
|
||||||
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
||||||
@@ -156,17 +216,22 @@ class DDPClient:
|
|||||||
start = i * bytes_per_packet
|
start = i * bytes_per_packet
|
||||||
end = min(start + bytes_per_packet, total_bytes)
|
end = min(start + bytes_per_packet, total_bytes)
|
||||||
chunk = bytes(pixel_bytes[start:end])
|
chunk = bytes(pixel_bytes[start:end])
|
||||||
|
is_last = (i == num_packets - 1)
|
||||||
|
|
||||||
# Increment sequence number
|
# Increment sequence number
|
||||||
self._sequence = (self._sequence + 1) % 256
|
self._sequence = (self._sequence + 1) % 256
|
||||||
|
|
||||||
# Build and send packet
|
# Build and send packet (set PUSH on last packet)
|
||||||
packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence)
|
packet = self._build_ddp_packet(
|
||||||
|
chunk, offset=start,
|
||||||
|
sequence=self._sequence, push=is_last,
|
||||||
|
)
|
||||||
self._transport.sendto(packet)
|
self._transport.sendto(packet)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Sent DDP packet {i+1}/{num_packets}: "
|
f"Sent DDP packet {i+1}/{num_packets}: "
|
||||||
f"{len(chunk)} bytes at offset {start}"
|
f"{len(chunk)} bytes at offset {start}"
|
||||||
|
f"{' [PUSH]' if is_last else ''}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"""WLED client for controlling LED devices via HTTP or DDP."""
|
"""WLED client for controlling LED devices via HTTP or DDP."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Tuple, Optional, Dict, Any
|
from typing import List, Tuple, Optional, Dict, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.core.ddp_client import DDPClient
|
from wled_controller.core.ddp_client import BusConfig, DDPClient
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ class WLEDInfo:
|
|||||||
ip: str
|
ip: str
|
||||||
rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel)
|
rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel)
|
||||||
color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR
|
color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR
|
||||||
|
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
|
||||||
|
|
||||||
|
|
||||||
class WLEDClient:
|
class WLEDClient:
|
||||||
@@ -100,8 +101,10 @@ class WLEDClient:
|
|||||||
|
|
||||||
# Create DDP client if needed
|
# Create DDP client if needed
|
||||||
if self.use_ddp:
|
if self.use_ddp:
|
||||||
# DDP always uses RGB mode (3 bytes/LED) - WLED handles RGBW and color order internally
|
self._ddp_client = DDPClient(self.host, rgbw=False)
|
||||||
self._ddp_client = DDPClient(self.host, rgbw=False, color_order=1)
|
# Pass per-bus config so DDP client can apply per-bus color reordering
|
||||||
|
if info.buses:
|
||||||
|
self._ddp_client.set_buses(info.buses)
|
||||||
await self._ddp_client.connect()
|
await self._ddp_client.connect()
|
||||||
|
|
||||||
# Turn on the device and disable Audio Reactive mode for DDP
|
# Turn on the device and disable Audio Reactive mode for DDP
|
||||||
@@ -230,14 +233,32 @@ class WLEDClient:
|
|||||||
data = await self._request("GET", "/json/info")
|
data = await self._request("GET", "/json/info")
|
||||||
leds_info = data.get("leds", {})
|
leds_info = data.get("leds", {})
|
||||||
|
|
||||||
# Get LED configuration for color order
|
# Get LED configuration for color order and per-bus info
|
||||||
cfg_data = await self._request("GET", "/json/cfg")
|
cfg_data = await self._request("GET", "/json/cfg")
|
||||||
color_order = 1 # Default to RGB
|
color_order = 1 # Default to RGB
|
||||||
|
buses: List[BusConfig] = []
|
||||||
if "hw" in cfg_data and "led" in cfg_data["hw"]:
|
if "hw" in cfg_data and "led" in cfg_data["hw"]:
|
||||||
led_cfg = cfg_data["hw"]["led"]
|
led_cfg = cfg_data["hw"]["led"]
|
||||||
if "ins" in led_cfg and len(led_cfg["ins"]) > 0:
|
ins_list = led_cfg.get("ins", [])
|
||||||
|
if ins_list:
|
||||||
# Use color order from first LED strip
|
# Use color order from first LED strip
|
||||||
color_order = led_cfg["ins"][0].get("order", 1)
|
color_order = ins_list[0].get("order", 1)
|
||||||
|
|
||||||
|
# Parse all buses for per-segment color reordering
|
||||||
|
order_names = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
|
||||||
|
for idx, bus_cfg in enumerate(ins_list):
|
||||||
|
bus = BusConfig(
|
||||||
|
start=bus_cfg.get("start", 0),
|
||||||
|
length=bus_cfg.get("len", 0),
|
||||||
|
color_order=bus_cfg.get("order", 1),
|
||||||
|
)
|
||||||
|
buses.append(bus)
|
||||||
|
logger.info(
|
||||||
|
f"WLED bus {idx}: LEDs {bus.start}-{bus.start + bus.length - 1}, "
|
||||||
|
f"pin={bus_cfg.get('pin', '?')}, "
|
||||||
|
f"order={order_names.get(bus.color_order, '?')} ({bus.color_order}), "
|
||||||
|
f"type={bus_cfg.get('type', '?')}"
|
||||||
|
)
|
||||||
|
|
||||||
return WLEDInfo(
|
return WLEDInfo(
|
||||||
name=data.get("name", "Unknown"),
|
name=data.get("name", "Unknown"),
|
||||||
@@ -247,8 +268,9 @@ class WLEDClient:
|
|||||||
product=data.get("product", "FOSS"),
|
product=data.get("product", "FOSS"),
|
||||||
mac=data.get("mac", ""),
|
mac=data.get("mac", ""),
|
||||||
ip=data.get("ip", ""),
|
ip=data.get("ip", ""),
|
||||||
rgbw=leds_info.get("rgbw", False), # Auto-detect RGBW vs RGB
|
rgbw=leds_info.get("rgbw", False),
|
||||||
color_order=color_order, # Auto-detect color order (0=GRB, 1=RGB, etc.)
|
color_order=color_order,
|
||||||
|
buses=buses,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ const API_BASE = '/api/v1';
|
|||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
let apiKey = null;
|
let apiKey = null;
|
||||||
|
|
||||||
|
// Toggle hint description visibility next to a label
|
||||||
|
function toggleHint(btn) {
|
||||||
|
const hint = btn.closest('.label-row').nextElementSibling;
|
||||||
|
if (hint && hint.classList.contains('input-hint')) {
|
||||||
|
const visible = hint.style.display !== 'none';
|
||||||
|
hint.style.display = visible ? 'none' : 'block';
|
||||||
|
btn.classList.toggle('active', !visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
|
// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
|
||||||
// Prevents accidental close when user drags text selection outside the dialog.
|
// Prevents accidental close when user drags text selection outside the dialog.
|
||||||
function setupBackdropClose(modal, closeFn) {
|
function setupBackdropClose(modal, closeFn) {
|
||||||
|
|||||||
@@ -203,15 +203,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
|
<div class="label-row">
|
||||||
|
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
||||||
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||||
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
<div class="label-row">
|
||||||
|
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
|
||||||
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
||||||
<small class="input-hint" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="settings-error" class="error-message" style="display: none;"></div>
|
<div id="settings-error" class="error-message" style="display: none;"></div>
|
||||||
@@ -236,35 +242,47 @@
|
|||||||
<input type="hidden" id="stream-selector-device-id">
|
<input type="hidden" id="stream-selector-device-id">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
|
||||||
<select id="stream-selector-stream"></select>
|
<select id="stream-selector-stream"></select>
|
||||||
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
|
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
|
||||||
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
|
||||||
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
|
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
|
||||||
<small class="input-hint" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
||||||
<select id="stream-selector-interpolation">
|
<select id="stream-selector-interpolation">
|
||||||
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
|
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
|
||||||
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
|
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
|
||||||
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
|
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-selector-smoothing">
|
<div class="label-row">
|
||||||
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
|
<label for="stream-selector-smoothing">
|
||||||
<span id="stream-selector-smoothing-value">0.3</span>
|
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
|
||||||
</label>
|
<span id="stream-selector-smoothing-value">0.3</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
||||||
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
|
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
|
||||||
<small class="input-hint" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
||||||
@@ -290,7 +308,11 @@
|
|||||||
Please enter your API key to authenticate and access the WLED Grab.
|
Please enter your API key to authenticate and access the WLED Grab.
|
||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
|
<div class="label-row">
|
||||||
|
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
|
||||||
<div class="password-input-wrapper">
|
<div class="password-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -303,7 +325,6 @@
|
|||||||
👁️
|
👁️
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="api-key-error" class="error-message" style="display: none;"></div>
|
<div id="api-key-error" class="error-message" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,7 +401,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
|
<div class="label-row">
|
||||||
|
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="templates.engine.hint">Select the screen capture technology to use</small>
|
||||||
<select id="template-engine" onchange="onEngineChange()" required>
|
<select id="template-engine" onchange="onEngineChange()" required>
|
||||||
<option value="" data-i18n="templates.engine.select">Select an engine...</option>
|
<option value="" data-i18n="templates.engine.select">Select an engine...</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -506,18 +531,30 @@
|
|||||||
<!-- Raw stream fields -->
|
<!-- Raw stream fields -->
|
||||||
<div id="stream-raw-fields">
|
<div id="stream-raw-fields">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="streams.display">Display:</label>
|
<div class="label-row">
|
||||||
|
<label data-i18n="streams.display">Display:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
|
||||||
<input type="hidden" id="stream-display-index" value="">
|
<input type="hidden" id="stream-display-index" value="">
|
||||||
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value)">
|
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value)">
|
||||||
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="streams.capture_template.hint">Engine template defining how the screen is captured</small>
|
||||||
<select id="stream-capture-template"></select>
|
<select id="stream-capture-template"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="streams.target_fps.hint">Target frames per second for capture (10-90)</small>
|
||||||
<div class="slider-row">
|
<div class="slider-row">
|
||||||
<input type="range" id="stream-target-fps" min="10" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
|
<input type="range" id="stream-target-fps" min="10" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
|
||||||
<span id="stream-target-fps-value" class="slider-value">30</span>
|
<span id="stream-target-fps-value" class="slider-value">30</span>
|
||||||
@@ -528,11 +565,19 @@
|
|||||||
<!-- Processed stream fields -->
|
<!-- Processed stream fields -->
|
||||||
<div id="stream-processed-fields" style="display: none;">
|
<div id="stream-processed-fields" style="display: none;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-source" data-i18n="streams.source">Source Stream:</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-source" data-i18n="streams.source">Source Stream:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="streams.source.hint">The stream to apply processing filters to</small>
|
||||||
<select id="stream-source"></select>
|
<select id="stream-source"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="streams.pp_template.hint">Filter template to apply to the source stream</small>
|
||||||
<select id="stream-pp-template"></select>
|
<select id="stream-pp-template"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,9 +585,12 @@
|
|||||||
<!-- Static image fields -->
|
<!-- Static image fields -->
|
||||||
<div id="stream-static-image-fields" style="display: none;">
|
<div id="stream-static-image-fields" style="display: none;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
|
<div class="label-row">
|
||||||
|
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
|
||||||
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
|
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
|
||||||
<small class="form-hint" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
|
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
|
||||||
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
|
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"templates.description.label": "Description (optional):",
|
"templates.description.label": "Description (optional):",
|
||||||
"templates.description.placeholder": "Describe this template...",
|
"templates.description.placeholder": "Describe this template...",
|
||||||
"templates.engine": "Capture Engine:",
|
"templates.engine": "Capture Engine:",
|
||||||
|
"templates.engine.hint": "Select the screen capture technology to use",
|
||||||
"templates.engine.select": "Select an engine...",
|
"templates.engine.select": "Select an engine...",
|
||||||
"templates.engine.unavailable": "Unavailable",
|
"templates.engine.unavailable": "Unavailable",
|
||||||
"templates.engine.unavailable.hint": "This engine is not available on your system",
|
"templates.engine.unavailable.hint": "This engine is not available on your system",
|
||||||
@@ -215,10 +216,15 @@
|
|||||||
"streams.type.raw": "Screen Capture",
|
"streams.type.raw": "Screen Capture",
|
||||||
"streams.type.processed": "Processed",
|
"streams.type.processed": "Processed",
|
||||||
"streams.display": "Display:",
|
"streams.display": "Display:",
|
||||||
|
"streams.display.hint": "Which screen to capture",
|
||||||
"streams.capture_template": "Engine Template:",
|
"streams.capture_template": "Engine Template:",
|
||||||
|
"streams.capture_template.hint": "Engine template defining how the screen is captured",
|
||||||
"streams.target_fps": "Target FPS:",
|
"streams.target_fps": "Target FPS:",
|
||||||
|
"streams.target_fps.hint": "Target frames per second for capture (10-90)",
|
||||||
"streams.source": "Source Stream:",
|
"streams.source": "Source Stream:",
|
||||||
|
"streams.source.hint": "The stream to apply processing filters to",
|
||||||
"streams.pp_template": "Filter Template:",
|
"streams.pp_template": "Filter Template:",
|
||||||
|
"streams.pp_template.hint": "Filter template to apply to the source stream",
|
||||||
"streams.description_label": "Description (optional):",
|
"streams.description_label": "Description (optional):",
|
||||||
"streams.description_placeholder": "Describe this stream...",
|
"streams.description_placeholder": "Describe this stream...",
|
||||||
"streams.created": "Stream created successfully",
|
"streams.created": "Stream created successfully",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"templates.description.label": "Описание (необязательно):",
|
"templates.description.label": "Описание (необязательно):",
|
||||||
"templates.description.placeholder": "Опишите этот шаблон...",
|
"templates.description.placeholder": "Опишите этот шаблон...",
|
||||||
"templates.engine": "Движок Захвата:",
|
"templates.engine": "Движок Захвата:",
|
||||||
|
"templates.engine.hint": "Выберите технологию захвата экрана",
|
||||||
"templates.engine.select": "Выберите движок...",
|
"templates.engine.select": "Выберите движок...",
|
||||||
"templates.engine.unavailable": "Недоступен",
|
"templates.engine.unavailable": "Недоступен",
|
||||||
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
|
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
|
||||||
@@ -215,10 +216,15 @@
|
|||||||
"streams.type.raw": "Захват экрана",
|
"streams.type.raw": "Захват экрана",
|
||||||
"streams.type.processed": "Обработанный",
|
"streams.type.processed": "Обработанный",
|
||||||
"streams.display": "Дисплей:",
|
"streams.display": "Дисплей:",
|
||||||
|
"streams.display.hint": "Какой экран захватывать",
|
||||||
"streams.capture_template": "Шаблон Движка:",
|
"streams.capture_template": "Шаблон Движка:",
|
||||||
|
"streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана",
|
||||||
"streams.target_fps": "Целевой FPS:",
|
"streams.target_fps": "Целевой FPS:",
|
||||||
|
"streams.target_fps.hint": "Целевое количество кадров в секунду (10-90)",
|
||||||
"streams.source": "Исходный Поток:",
|
"streams.source": "Исходный Поток:",
|
||||||
|
"streams.source.hint": "Поток, к которому применяются фильтры обработки",
|
||||||
"streams.pp_template": "Шаблон Фильтра:",
|
"streams.pp_template": "Шаблон Фильтра:",
|
||||||
|
"streams.pp_template.hint": "Шаблон фильтра для применения к исходному потоку",
|
||||||
"streams.description_label": "Описание (необязательно):",
|
"streams.description_label": "Описание (необязательно):",
|
||||||
"streams.description_placeholder": "Опишите этот поток...",
|
"streams.description_placeholder": "Опишите этот поток...",
|
||||||
"streams.created": "Поток успешно создан",
|
"streams.created": "Поток успешно создан",
|
||||||
|
|||||||
@@ -966,9 +966,49 @@ input:-webkit-autofill:focus {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-row label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-toggle {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-toggle.active {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--primary-color, #4CAF50);
|
||||||
|
border-color: var(--primary-color, #4CAF50);
|
||||||
|
}
|
||||||
|
|
||||||
.input-hint {
|
.input-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
margin: 0 0 6px 0;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user