Compare commits

...

4 Commits

Author SHA1 Message Date
a39dc1b06a Add mock LED device type for testing without hardware
Virtual device with configurable LED count, RGB/RGBW mode, and simulated
send latency. Includes full provider/client implementation, API schema
support, and frontend add/settings modal integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:53 +03:00
dc12452bcd Fix section toggle firing on filter input drag
Changed header collapse from click to mousedown so dragging from the
filter input to outside no longer triggers a toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:45 +03:00
0b89731d0c Add palette type badge to audio color strip source cards
Shows palette name for spectrum and beat_pulse visualization modes,
matching the existing pattern on effect cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:40 +03:00
858a8e3ac2 Rework root README to reflect current project state
Rewritten from scratch as "LED Grab" with organized feature sections,
full architecture tree, actual config examples, and comprehensive API listing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:34 +03:00
19 changed files with 462 additions and 152 deletions

289
README.md
View File

@@ -1,194 +1,219 @@
# WLED Screen Controller # LED Grab
Ambient lighting controller that synchronizes WLED devices with your screen content for an immersive viewing experience. Ambient lighting system that captures screen content and drives LED strips in real time. Supports WLED, Adalight, AmbileD, and DDP devices with audio-reactive effects, pattern generation, and automated profile switching.
## Overview ## What It Does
This project consists of two components: The server captures pixels from a screen (or Android device via ADB), extracts border colors, applies post-processing filters, and streams the result to LED strips at up to 60 fps. A built-in web dashboard provides device management, calibration, live LED preview, and real-time metrics — no external UI required.
1. **Python Server** - Captures screen border pixels and sends color data to WLED devices via REST API A Home Assistant integration exposes devices as entities for smart home automation.
2. **Home Assistant Integration** - Controls and monitors the server from Home Assistant OS
## Features ## Features
- 🖥️ **Multi-Monitor Support** - Select which display to capture ### Screen Capture
-**Configurable FPS** - Adjust update rate (1-60 FPS)
- 🎨 **Smart Calibration** - Map screen edges to LED positions - Multi-monitor support with per-target display selection
- 🔌 **REST API** - Full control via HTTP endpoints - 5 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB)
- 🏠 **Home Assistant Integration** - Native HAOS support with entities - Configurable capture regions, FPS, and border width
- 🐳 **Docker Support** - Easy deployment with Docker Compose - Capture templates for reusable configurations
- 📊 **Real-time Metrics** - Monitor FPS, status, and performance
### LED Device Support
- WLED (HTTP/UDP) with mDNS auto-discovery
- Adalight (serial) — Arduino-compatible LED controllers
- AmbileD (serial)
- DDP (Distributed Display Protocol, UDP)
- Serial port auto-detection and baud rate configuration
### Color Processing
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip
- Reusable post-processing templates
- Color strip sources: audio-reactive, pattern generator, composite layering, audio-to-color mapping
- Pattern templates with customizable effects
### Audio Integration (Windows)
- Multichannel audio capture from any system device (input or loopback)
- Per-channel mono extraction
- Audio-reactive color strip sources driven by frequency analysis
### Automation
- Profile engine with condition-based switching (time of day, active window, etc.)
- Dynamic brightness value sources (schedule-based, scene-aware)
- Key Colors (KC) targets with live WebSocket color streaming
### Dashboard
- Web UI at `http://localhost:8080` — no installation needed on the client side
- Device management with auto-discovery wizard
- Visual calibration editor with overlay preview
- Live LED strip preview via WebSocket
- Real-time FPS, latency, and uptime charts
- Localized in English and Russian
### Home Assistant Integration
- HACS-compatible custom component
- Light, switch, sensor, and number entities per device
- Real-time metrics via data coordinator
- WebSocket-based live LED preview in HA
## Requirements ## Requirements
### Server - Python 3.11+ (or Docker)
- Python 3.11 or higher - A supported LED device on the local network or connected via USB
- Windows, Linux, or macOS - Windows for GPU-accelerated capture engines and audio capture; Linux/macOS supported via MSS
- WLED device on the same network
### Home Assistant Integration
- Home Assistant OS 2023.1 or higher
- Running WLED Screen Controller server
## Quick Start ## Quick Start
### Server Installation
1. **Clone the repository**
```bash ```bash
git clone https://github.com/yourusername/wled-screen-controller.git git clone https://github.com/yourusername/wled-screen-controller.git
cd wled-screen-controller/server cd wled-screen-controller/server
```
2. **Install dependencies** # Option A: Docker (recommended)
```bash docker-compose up -d
# Option B: Python
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
pip install . pip install .
``` export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
3. **Run the server**
```bash
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080 uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
``` ```
4. **Access the API** Open `http://localhost:8080` to access the dashboard. The default API key for development is `development-key-change-in-production`.
- API: http://localhost:8080
- Interactive docs: http://localhost:8080/docs
### Docker Installation See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including Docker manual builds and Home Assistant setup.
```bash ## Architecture
cd server
docker-compose up -d ```text
wled-screen-controller/
├── server/ # Python FastAPI backend
│ ├── src/wled_controller/
│ │ ├── main.py # Application entry point
│ │ ├── config.py # YAML + env var configuration
│ │ ├── api/
│ │ │ ├── routes/ # REST + WebSocket endpoints
│ │ │ └── schemas/ # Pydantic request/response models
│ │ ├── core/
│ │ │ ├── capture/ # Screen capture, calibration, pixel processing
│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy backends
│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP clients
│ │ │ ├── audio/ # Audio capture (Windows)
│ │ │ ├── filters/ # Post-processing filter pipeline
│ │ │ ├── processing/ # Stream orchestration and target processors
│ │ │ └── profiles/ # Condition-based profile automation
│ │ ├── storage/ # JSON-based persistence layer
│ │ ├── static/ # Web dashboard (vanilla JS, CSS, HTML)
│ │ │ ├── js/core/ # API client, state, i18n, modals, events
│ │ │ ├── js/features/ # Feature modules (devices, streams, targets, etc.)
│ │ │ ├── css/ # Stylesheets
│ │ │ └── locales/ # en.json, ru.json
│ │ └── utils/ # Logging, monitor detection
│ ├── config/ # default_config.yaml
│ ├── tests/ # pytest suite
│ ├── Dockerfile
│ └── docker-compose.yml
├── custom_components/ # Home Assistant integration (HACS)
│ └── wled_screen_controller/
├── docs/
│ ├── API.md # REST API reference
│ └── CALIBRATION.md # LED calibration guide
├── INSTALLATION.md
└── LICENSE # MIT
``` ```
## Configuration ## Configuration
Edit `server/config/default_config.yaml`: Edit `server/config/default_config.yaml` or use environment variables with the `LED_GRAB_` prefix:
```yaml ```yaml
server: server:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 port: 8080
log_level: "INFO"
processing: auth:
default_fps: 30 api_keys:
border_width: 10 dev: "development-key-change-in-production"
wled: storage:
timeout: 5 devices_file: "data/devices.json"
retry_attempts: 3 templates_file: "data/capture_templates.json"
logging:
format: "json"
file: "logs/wled_controller.log"
max_size_mb: 100
``` ```
## API Usage Environment variable override example: `LED_GRAB_SERVER__PORT=9090`.
### Attach a WLED Device ## API
```bash The server exposes a REST API (with Swagger docs at `/docs`) covering:
curl -X POST http://localhost:8080/api/v1/devices \
-H "Content-Type: application/json" \
-d '{
"name": "Living Room TV",
"url": "http://192.168.1.100",
"led_count": 150
}'
```
### Start Processing - **Devices** — CRUD, discovery, validation, state, metrics
- **Capture Templates** — Screen capture configurations
- **Picture Sources** — Screen capture stream definitions
- **Picture Targets** — LED target management, start/stop processing
- **Post-Processing Templates** — Filter pipeline configurations
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
- **Audio Sources** — Multichannel and mono audio device configuration
- **Pattern Templates** — Effect pattern definitions
- **Value Sources** — Dynamic brightness/value providers
- **Key Colors Targets** — KC targets with WebSocket live color stream
- **Profiles** — Condition-based automation profiles
```bash All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
```
### Get Status See [docs/API.md](docs/API.md) for the full reference.
```bash
curl http://localhost:8080/api/v1/devices/{device_id}/state
```
See [API Documentation](docs/API.md) for complete API reference.
## Calibration ## Calibration
The calibration system maps screen border pixels to LED positions. See [Calibration Guide](docs/CALIBRATION.md) for details. The calibration system maps screen border pixels to physical LED positions. Configure layout direction, start position, and per-edge segments through the web dashboard or API.
Example calibration: See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
```json
{
"layout": "clockwise",
"start_position": "bottom_left",
"segments": [
{"edge": "bottom", "led_start": 0, "led_count": 40},
{"edge": "right", "led_start": 40, "led_count": 30},
{"edge": "top", "led_start": 70, "led_count": 40},
{"edge": "left", "led_start": 110, "led_count": 40}
]
}
```
## Home Assistant Integration ## Home Assistant
1. Copy `homeassistant/custom_components/wled_screen_controller` to your Home Assistant `custom_components` folder Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
2. Restart Home Assistant
3. Go to Settings → Integrations → Add Integration See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
4. Search for "WLED Screen Controller"
5. Enter your server URL
## Development ## Development
### Running Tests
```bash ```bash
cd server cd server
pytest tests/ -v
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Format and lint
black src/ tests/
ruff check src/ tests/
``` ```
### Project Structure Optional high-performance capture engines (Windows only):
```bash
pip install -e ".[perf]"
``` ```
wled-screen-controller/
├── server/ # Python FastAPI server
│ ├── src/wled_controller/ # Main application code
│ ├── tests/ # Unit and integration tests
│ ├── config/ # Configuration files
│ └── pyproject.toml # Python dependencies & project config
├── homeassistant/ # Home Assistant integration
│ └── custom_components/
└── docs/ # Documentation
```
## Troubleshooting
### Screen capture fails
- **Windows**: Ensure Python has screen capture permissions
- **Linux**: Install X11 dependencies: `apt-get install libxcb1 libxcb-randr0`
- **macOS**: Grant screen recording permission in System Preferences
### WLED not responding
- Verify WLED device is on the same network
- Check firewall settings
- Test connection: `curl http://YOUR_WLED_IP/json/info`
### Low FPS
- Reduce `border_width` in configuration
- Lower target FPS
- Check network latency to WLED device
- Reduce LED count
## License ## License
MIT License - see [LICENSE](LICENSE) file MIT see [LICENSE](LICENSE).
## Contributing
Contributions welcome! Please open an issue or pull request.
## Acknowledgments ## Acknowledgments
- [WLED](https://github.com/Aircoookie/WLED) - Amazing LED control software - [WLED](https://github.com/Aircoookie/WLED) LED control firmware
- [FastAPI](https://fastapi.tiangolo.com/) - Modern Python web framework - [FastAPI](https://fastapi.tiangolo.com/) Python web framework
- [mss](https://python-mss.readthedocs.io/) - Fast screen capture library - [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture
## Support
- GitHub Issues: [Report a bug](https://github.com/yourusername/wled-screen-controller/issues)
- Discussions: [Ask a question](https://github.com/yourusername/wled-screen-controller/discussions)

View File

@@ -44,6 +44,8 @@ def _device_to_response(device) -> DeviceResponse:
enabled=device.enabled, enabled=device.enabled,
baud_rate=device.baud_rate, baud_rate=device.baud_rate,
auto_shutdown=device.auto_shutdown, auto_shutdown=device.auto_shutdown,
send_latency_ms=device.send_latency_ms,
rgbw=device.rgbw,
capabilities=sorted(get_device_capabilities(device.device_type)), capabilities=sorted(get_device_capabilities(device.device_type)),
created_at=device.created_at, created_at=device.created_at,
updated_at=device.updated_at, updated_at=device.updated_at,
@@ -116,6 +118,8 @@ async def create_device(
device_type=device_type, device_type=device_type,
baud_rate=device_data.baud_rate, baud_rate=device_data.baud_rate,
auto_shutdown=auto_shutdown, auto_shutdown=auto_shutdown,
send_latency_ms=device_data.send_latency_ms or 0,
rgbw=device_data.rgbw or False,
) )
# Register in processor manager for health monitoring # Register in processor manager for health monitoring
@@ -242,6 +246,8 @@ async def update_device(
led_count=update_data.led_count, led_count=update_data.led_count,
baud_rate=update_data.baud_rate, baud_rate=update_data.baud_rate,
auto_shutdown=update_data.auto_shutdown, auto_shutdown=update_data.auto_shutdown,
send_latency_ms=update_data.send_latency_ms,
rgbw=update_data.rgbw,
) )
# Sync connection info in processor manager # Sync connection info in processor manager

View File

@@ -15,6 +15,8 @@ class DeviceCreate(BaseModel):
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)") led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(default=None, description="Turn off device when server stops (defaults to true for adalight)") auto_shutdown: Optional[bool] = Field(default=None, description="Turn off device when server stops (defaults to true for adalight)")
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
class DeviceUpdate(BaseModel): class DeviceUpdate(BaseModel):
@@ -26,6 +28,8 @@ class DeviceUpdate(BaseModel):
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)") led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops") auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
class Calibration(BaseModel): class Calibration(BaseModel):
@@ -93,6 +97,8 @@ class DeviceResponse(BaseModel):
enabled: bool = Field(description="Whether device is enabled") enabled: bool = Field(description="Whether device is enabled")
baud_rate: Optional[int] = Field(None, description="Serial baud rate") baud_rate: Optional[int] = Field(None, description="Serial baud rate")
auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop") auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop")
send_latency_ms: int = Field(default=0, description="Simulated send latency in ms (mock devices)")
rgbw: bool = Field(default=False, description="RGBW mode (mock devices)")
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities") capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -279,5 +279,8 @@ def _register_builtin_providers():
from wled_controller.core.devices.ambiled_provider import AmbiLEDDeviceProvider from wled_controller.core.devices.ambiled_provider import AmbiLEDDeviceProvider
register_provider(AmbiLEDDeviceProvider()) register_provider(AmbiLEDDeviceProvider())
from wled_controller.core.devices.mock_provider import MockDeviceProvider
register_provider(MockDeviceProvider())
_register_builtin_providers() _register_builtin_providers()

View File

@@ -0,0 +1,73 @@
"""Mock LED client — simulates an LED strip with configurable latency for testing."""
import asyncio
from datetime import datetime
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class MockClient(LEDClient):
"""LED client that simulates an LED strip without real hardware.
Useful for load testing, development, and CI environments.
"""
def __init__(
self,
url: str = "",
led_count: int = 0,
send_latency_ms: int = 0,
rgbw: bool = False,
**kwargs,
):
self._led_count = led_count
self._latency = send_latency_ms / 1000.0 # convert to seconds
self._rgbw = rgbw
self._connected = False
async def connect(self) -> bool:
self._connected = True
logger.info(
f"Mock device connected ({self._led_count} LEDs, "
f"{'RGBW' if self._rgbw else 'RGB'}, "
f"{int(self._latency * 1000)}ms latency)"
)
return True
async def close(self) -> None:
self._connected = False
logger.info("Mock device disconnected")
@property
def is_connected(self) -> bool:
return self._connected
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self._connected:
return False
if self._latency > 0:
await asyncio.sleep(self._latency)
return True
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
)

View File

@@ -0,0 +1,43 @@
"""Mock device provider — virtual LED strip for testing."""
from datetime import datetime
from typing import List
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.mock_client import MockClient
class MockDeviceProvider(LEDDeviceProvider):
"""Provider for virtual mock LED devices."""
@property
def device_type(self) -> str:
return "mock"
@property
def capabilities(self) -> set:
return {"manual_led_count", "power_control", "brightness_control"}
def create_client(self, url: str, **kwargs) -> LEDClient:
kwargs.pop("use_ddp", None)
return MockClient(url, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.utcnow())
async def validate_device(self, url: str) -> dict:
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
return []
async def get_power(self, url: str, **kwargs) -> bool:
return True
async def set_power(self, url: str, on: bool, **kwargs) -> None:
pass

View File

@@ -129,6 +129,15 @@ class ProcessorManager:
ds = self._devices.get(device_id) ds = self._devices.get(device_id)
if ds is None: if ds is None:
return None return None
# Read mock-specific fields from persistent storage
send_latency_ms = 0
rgbw = False
if self._device_store:
dev = self._device_store.get_device(ds.device_id)
if dev:
send_latency_ms = getattr(dev, "send_latency_ms", 0)
rgbw = getattr(dev, "rgbw", False)
return DeviceInfo( return DeviceInfo(
device_id=ds.device_id, device_id=ds.device_id,
device_url=ds.device_url, device_url=ds.device_url,
@@ -137,6 +146,8 @@ class ProcessorManager:
baud_rate=ds.baud_rate, baud_rate=ds.baud_rate,
software_brightness=ds.software_brightness, software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active, test_mode_active=ds.test_mode_active,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
) )
# ===== EVENT SYSTEM (state change notifications) ===== # ===== EVENT SYSTEM (state change notifications) =====

View File

@@ -69,6 +69,8 @@ class DeviceInfo:
baud_rate: Optional[int] = None baud_rate: Optional[int] = None
software_brightness: int = 255 software_brightness: int = 255
test_mode_active: bool = False test_mode_active: bool = False
send_latency_ms: int = 0
rgbw: bool = False
@dataclass @dataclass

View File

@@ -86,6 +86,8 @@ class WledTargetProcessor(TargetProcessor):
device_info.device_type, device_info.device_url, device_info.device_type, device_info.device_url,
use_ddp=True, led_count=device_info.led_count, use_ddp=True, led_count=device_info.led_count,
baud_rate=device_info.baud_rate, baud_rate=device_info.baud_rate,
send_latency_ms=device_info.send_latency_ms,
rgbw=device_info.rgbw,
) )
await self._led_client.connect() await self._led_client.connect()
logger.info( logger.info(

View File

@@ -74,6 +74,10 @@ export function isSerialDevice(type) {
return type === 'adalight' || type === 'ambiled'; return type === 'adalight' || type === 'ambiled';
} }
export function isMockDevice(type) {
return type === 'mock';
}
export function handle401Error() { export function handle401Error() {
if (!apiKey) return; // Already handled or no session if (!apiKey) return; // Already handled or no session
localStorage.removeItem('wled_api_key'); localStorage.removeItem('wled_api_key');

View File

@@ -95,13 +95,13 @@ export class CardSection {
const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`); const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`);
if (!header || !content) return; if (!header || !content) return;
header.addEventListener('click', (e) => { header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cs-filter')) return; if (e.target.closest('.cs-filter')) return;
this._toggleCollapse(header, content); this._toggleCollapse(header, content);
}); });
if (filterInput) { if (filterInput) {
filterInput.addEventListener('click', (e) => e.stopPropagation()); filterInput.addEventListener('mousedown', (e) => e.stopPropagation());
let timer = null; let timer = null;
filterInput.addEventListener('input', () => { filterInput.addEventListener('input', () => {
clearTimeout(timer); clearTimeout(timer);

View File

@@ -653,9 +653,12 @@ export function createColorStripCard(source, pictureSourceMap) {
} else if (isAudio) { } else if (isAudio) {
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum'; const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
const sensitivityVal = (source.sensitivity || 1.0).toFixed(1); const sensitivityVal = (source.sensitivity || 1.0).toFixed(1);
const srcLabel = source.audio_source_id || ''; const vizMode = source.visualization_mode || 'spectrum';
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette;
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
propsHtml = ` propsHtml = `
<span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</span> <span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</span>
${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">🎨 ${escapeHtml(audioPaletteLabel)}</span>` : ''}
<span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">📶 ${sensitivityVal}</span> <span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">📶 ${sensitivityVal}</span>
${source.audio_source_id ? `<span class="stream-card-prop" title="${t('color_strip.audio.source')}">🔊</span>` : ''} ${source.audio_source_id ? `<span class="stream-card-prop" title="${t('color_strip.audio.source')}">🔊</span>` : ''}
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''} ${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}

View File

@@ -6,7 +6,7 @@ import {
_discoveryScanRunning, set_discoveryScanRunning, _discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, fetchWithAuth, isSerialDevice, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -23,6 +23,8 @@ class AddDeviceModal extends Modal {
serialPort: document.getElementById('device-serial-port').value, serialPort: document.getElementById('device-serial-port').value,
ledCount: document.getElementById('device-led-count').value, ledCount: document.getElementById('device-led-count').value,
baudRate: document.getElementById('device-baud-rate').value, baudRate: document.getElementById('device-baud-rate').value,
ledType: document.getElementById('device-led-type')?.value || 'rgb',
sendLatency: document.getElementById('device-send-latency')?.value || '0',
}; };
} }
} }
@@ -37,16 +39,29 @@ export function onDeviceTypeChanged() {
const serialSelect = document.getElementById('device-serial-port'); const serialSelect = document.getElementById('device-serial-port');
const ledCountGroup = document.getElementById('device-led-count-group'); const ledCountGroup = document.getElementById('device-led-count-group');
const discoverySection = document.getElementById('discovery-section'); const discoverySection = document.getElementById('discovery-section');
const baudRateGroup = document.getElementById('device-baud-rate-group'); const baudRateGroup = document.getElementById('device-baud-rate-group');
const ledTypeGroup = document.getElementById('device-led-type-group');
const sendLatencyGroup = document.getElementById('device-send-latency-group');
if (isSerialDevice(deviceType)) { if (isMockDevice(deviceType)) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = '';
baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = '';
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
if (discoverySection) discoverySection.style.display = 'none';
} else if (isSerialDevice(deviceType)) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
urlInput.removeAttribute('required'); urlInput.removeAttribute('required');
serialGroup.style.display = ''; serialGroup.style.display = '';
serialSelect.setAttribute('required', ''); serialSelect.setAttribute('required', '');
ledCountGroup.style.display = ''; ledCountGroup.style.display = '';
baudRateGroup.style.display = ''; baudRateGroup.style.display = '';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
// Hide discovery list — serial port dropdown replaces it // Hide discovery list — serial port dropdown replaces it
if (discoverySection) discoverySection.style.display = 'none'; if (discoverySection) discoverySection.style.display = 'none';
// Populate from cache or show placeholder (lazy-load on focus) // Populate from cache or show placeholder (lazy-load on focus)
@@ -68,6 +83,8 @@ export function onDeviceTypeChanged() {
serialSelect.removeAttribute('required'); serialSelect.removeAttribute('required');
ledCountGroup.style.display = 'none'; ledCountGroup.style.display = 'none';
baudRateGroup.style.display = 'none'; baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
// Show cached results or trigger scan for WLED // Show cached results or trigger scan for WLED
if (deviceType in _discoveryCache) { if (deviceType in _discoveryCache) {
_renderDiscoveryList(); _renderDiscoveryList();
@@ -287,12 +304,19 @@ export async function handleAddDevice(event) {
const name = document.getElementById('device-name').value.trim(); const name = document.getElementById('device-name').value.trim();
const deviceType = document.getElementById('device-type')?.value || 'wled'; const deviceType = document.getElementById('device-type')?.value || 'wled';
const url = isSerialDevice(deviceType)
? document.getElementById('device-serial-port').value
: document.getElementById('device-url').value.trim();
const error = document.getElementById('add-device-error'); const error = document.getElementById('add-device-error');
if (!name || !url) { let url;
if (isMockDevice(deviceType)) {
const ledCount = document.getElementById('device-led-count')?.value || '60';
url = `mock://${ledCount}`;
} else if (isSerialDevice(deviceType)) {
url = document.getElementById('device-serial-port').value;
} else {
url = document.getElementById('device-url').value.trim();
}
if (!name || (!isMockDevice(deviceType) && !url)) {
error.textContent = 'Please fill in all fields'; error.textContent = 'Please fill in all fields';
error.style.display = 'block'; error.style.display = 'block';
return; return;
@@ -308,6 +332,12 @@ export async function handleAddDevice(event) {
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) { if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
body.baud_rate = parseInt(baudRateSelect.value, 10); body.baud_rate = parseInt(baudRateSelect.value, 10);
} }
if (isMockDevice(deviceType)) {
const sendLatency = document.getElementById('device-send-latency')?.value;
if (sendLatency) body.send_latency_ms = parseInt(sendLatency, 10);
const ledType = document.getElementById('device-led-type')?.value;
body.rgbw = ledType === 'rgbw';
}
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) { if (lastTemplateId) {
body.capture_template_id = lastTemplateId; body.capture_template_id = lastTemplateId;

View File

@@ -5,7 +5,7 @@
import { import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -23,10 +23,16 @@ class DeviceSettingsModal extends Modal {
state_check_interval: this.$('settings-health-interval').value, state_check_interval: this.$('settings-health-interval').value,
auto_shutdown: this.$('settings-auto-shutdown').checked, auto_shutdown: this.$('settings-auto-shutdown').checked,
led_count: this.$('settings-led-count').value, led_count: this.$('settings-led-count').value,
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
send_latency: document.getElementById('settings-send-latency')?.value || '0',
}; };
} }
_getUrl() { _getUrl() {
if (isMockDevice(this.deviceType)) {
const ledCount = this.$('settings-led-count')?.value || '60';
return `mock://${ledCount}`;
}
if (isSerialDevice(this.deviceType)) { if (isSerialDevice(this.deviceType)) {
return this.$('settings-serial-port').value; return this.$('settings-serial-port').value;
} }
@@ -166,9 +172,14 @@ export async function showSettings(deviceId) {
document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-health-interval').value = 30; document.getElementById('settings-health-interval').value = 30;
const isMock = isMockDevice(device.device_type);
const urlGroup = document.getElementById('settings-url-group'); const urlGroup = document.getElementById('settings-url-group');
const serialGroup = document.getElementById('settings-serial-port-group'); const serialGroup = document.getElementById('settings-serial-port-group');
if (isAdalight) { if (isMock) {
urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required');
serialGroup.style.display = 'none';
} else if (isAdalight) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required'); document.getElementById('settings-device-url').removeAttribute('required');
serialGroup.style.display = ''; serialGroup.style.display = '';
@@ -202,6 +213,23 @@ export async function showSettings(deviceId) {
baudRateGroup.style.display = 'none'; baudRateGroup.style.display = 'none';
} }
// Mock-specific fields
const ledTypeGroup = document.getElementById('settings-led-type-group');
const sendLatencyGroup = document.getElementById('settings-send-latency-group');
if (isMock) {
if (ledTypeGroup) {
ledTypeGroup.style.display = '';
document.getElementById('settings-led-type').value = device.rgbw ? 'rgbw' : 'rgb';
}
if (sendLatencyGroup) {
sendLatencyGroup.style.display = '';
document.getElementById('settings-send-latency').value = device.send_latency_ms || 0;
}
} else {
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
}
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown; document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
settingsModal.snapshot(); settingsModal.snapshot();
settingsModal.open(); settingsModal.open();
@@ -241,6 +269,12 @@ export async function saveDeviceSettings() {
const baudVal = document.getElementById('settings-baud-rate').value; const baudVal = document.getElementById('settings-baud-rate').value;
if (baudVal) body.baud_rate = parseInt(baudVal, 10); if (baudVal) body.baud_rate = parseInt(baudVal, 10);
} }
if (isMockDevice(settingsModal.deviceType)) {
const sendLatency = document.getElementById('settings-send-latency')?.value;
if (sendLatency !== undefined) body.send_latency_ms = parseInt(sendLatency, 10);
const ledType = document.getElementById('settings-led-type')?.value;
body.rgbw = ledType === 'rgbw';
}
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, { const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body) body: JSON.stringify(body)

View File

@@ -122,6 +122,10 @@
"device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)", "device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)",
"device.baud_rate": "Baud Rate:", "device.baud_rate": "Baud Rate:",
"device.baud_rate.hint": "Serial communication speed. Higher = more FPS but requires matching Arduino sketch.", "device.baud_rate.hint": "Serial communication speed. Higher = more FPS but requires matching Arduino sketch.",
"device.led_type": "LED Type:",
"device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)",
"device.send_latency": "Send Latency (ms):",
"device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds",
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)", "device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
"device.name": "Device Name:", "device.name": "Device Name:",
"device.name.placeholder": "Living Room TV", "device.name.placeholder": "Living Room TV",

View File

@@ -122,6 +122,10 @@
"device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)", "device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)",
"device.baud_rate": "Скорость порта:", "device.baud_rate": "Скорость порта:",
"device.baud_rate.hint": "Скорость серийного соединения. Выше = больше FPS, но требует соответствия скетчу Arduino.", "device.baud_rate.hint": "Скорость серийного соединения. Выше = больше FPS, но требует соответствия скетчу Arduino.",
"device.led_type": "Тип LED:",
"device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)",
"device.send_latency": "Задержка отправки (мс):",
"device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)", "device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:", "device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной", "device.name.placeholder": "ТВ в Гостиной",

View File

@@ -30,6 +30,8 @@ class Device:
baud_rate: Optional[int] = None, baud_rate: Optional[int] = None,
software_brightness: int = 255, software_brightness: int = 255,
auto_shutdown: bool = False, auto_shutdown: bool = False,
send_latency_ms: int = 0,
rgbw: bool = False,
created_at: Optional[datetime] = None, created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None, updated_at: Optional[datetime] = None,
): ):
@@ -42,6 +44,8 @@ class Device:
self.baud_rate = baud_rate self.baud_rate = baud_rate
self.software_brightness = software_brightness self.software_brightness = software_brightness
self.auto_shutdown = auto_shutdown self.auto_shutdown = auto_shutdown
self.send_latency_ms = send_latency_ms
self.rgbw = rgbw
self.created_at = created_at or datetime.utcnow() self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow() self.updated_at = updated_at or datetime.utcnow()
# Preserved from old JSON for migration — not written back # Preserved from old JSON for migration — not written back
@@ -65,6 +69,10 @@ class Device:
d["software_brightness"] = self.software_brightness d["software_brightness"] = self.software_brightness
if self.auto_shutdown: if self.auto_shutdown:
d["auto_shutdown"] = True d["auto_shutdown"] = True
if self.send_latency_ms:
d["send_latency_ms"] = self.send_latency_ms
if self.rgbw:
d["rgbw"] = True
return d return d
@classmethod @classmethod
@@ -84,6 +92,8 @@ class Device:
baud_rate=data.get("baud_rate"), baud_rate=data.get("baud_rate"),
software_brightness=data.get("software_brightness", 255), software_brightness=data.get("software_brightness", 255),
auto_shutdown=data.get("auto_shutdown", False), auto_shutdown=data.get("auto_shutdown", False),
send_latency_ms=data.get("send_latency_ms", 0),
rgbw=data.get("rgbw", False),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
) )
@@ -180,6 +190,8 @@ class DeviceStore:
device_type: str = "wled", device_type: str = "wled",
baud_rate: Optional[int] = None, baud_rate: Optional[int] = None,
auto_shutdown: bool = False, auto_shutdown: bool = False,
send_latency_ms: int = 0,
rgbw: bool = False,
) -> Device: ) -> Device:
"""Create a new device.""" """Create a new device."""
device_id = f"device_{uuid.uuid4().hex[:8]}" device_id = f"device_{uuid.uuid4().hex[:8]}"
@@ -192,6 +204,8 @@ class DeviceStore:
device_type=device_type, device_type=device_type,
baud_rate=baud_rate, baud_rate=baud_rate,
auto_shutdown=auto_shutdown, auto_shutdown=auto_shutdown,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
) )
self._devices[device_id] = device self._devices[device_id] = device
@@ -217,6 +231,8 @@ class DeviceStore:
enabled: Optional[bool] = None, enabled: Optional[bool] = None,
baud_rate: Optional[int] = None, baud_rate: Optional[int] = None,
auto_shutdown: Optional[bool] = None, auto_shutdown: Optional[bool] = None,
send_latency_ms: Optional[int] = None,
rgbw: Optional[bool] = None,
) -> Device: ) -> Device:
"""Update device.""" """Update device."""
device = self._devices.get(device_id) device = self._devices.get(device_id)
@@ -235,6 +251,10 @@ class DeviceStore:
device.baud_rate = baud_rate device.baud_rate = baud_rate
if auto_shutdown is not None: if auto_shutdown is not None:
device.auto_shutdown = auto_shutdown device.auto_shutdown = auto_shutdown
if send_latency_ms is not None:
device.send_latency_ms = send_latency_ms
if rgbw is not None:
device.rgbw = rgbw
device.updated_at = datetime.utcnow() device.updated_at = datetime.utcnow()
self.save() self.save()

View File

@@ -30,6 +30,7 @@
<option value="wled">WLED</option> <option value="wled">WLED</option>
<option value="adalight">Adalight</option> <option value="adalight">Adalight</option>
<option value="ambiled">AmbiLED</option> <option value="ambiled">AmbiLED</option>
<option value="mock">Mock</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -78,6 +79,25 @@
</select> </select>
<small id="baud-fps-hint" class="fps-hint" style="display:none"></small> <small id="baud-fps-hint" class="fps-hint" style="display:none"></small>
</div> </div>
<div class="form-group" id="device-led-type-group" style="display: none;">
<div class="label-row">
<label for="device-led-type" data-i18n="device.led_type">LED Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_type.hint">RGB (3 channels) or RGBW (4 channels with dedicated white)</small>
<select id="device-led-type">
<option value="rgb">RGB</option>
<option value="rgbw">RGBW</option>
</select>
</div>
<div class="form-group" id="device-send-latency-group" style="display: none;">
<div class="label-row">
<label for="device-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.send_latency.hint">Simulated network/serial delay per frame in milliseconds</small>
<input type="number" id="device-send-latency" min="0" max="5000" value="0">
</div>
<div id="add-device-error" class="error-message" style="display: none;"></div> <div id="add-device-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>

View File

@@ -58,6 +58,26 @@
<small id="settings-baud-fps-hint" class="fps-hint" style="display:none"></small> <small id="settings-baud-fps-hint" class="fps-hint" style="display:none"></small>
</div> </div>
<div class="form-group" id="settings-led-type-group" style="display: none;">
<div class="label-row">
<label for="settings-led-type" data-i18n="device.led_type">LED Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_type.hint">RGB (3 channels) or RGBW (4 channels with dedicated white)</small>
<select id="settings-led-type">
<option value="rgb">RGB</option>
<option value="rgbw">RGBW</option>
</select>
</div>
<div class="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row">
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.send_latency.hint">Simulated network/serial delay per frame in milliseconds</small>
<input type="number" id="settings-send-latency" min="0" max="5000" value="0">
</div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label> <label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>