Compare commits
4 Commits
e4c4301a7b
...
a39dc1b06a
| Author | SHA1 | Date | |
|---|---|---|---|
| a39dc1b06a | |||
| dc12452bcd | |||
| 0b89731d0c | |||
| 858a8e3ac2 |
305
README.md
305
README.md
@@ -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
|
||||
2. **Home Assistant Integration** - Controls and monitors the server from Home Assistant OS
|
||||
A Home Assistant integration exposes devices as entities for smart home automation.
|
||||
|
||||
## Features
|
||||
|
||||
- 🖥️ **Multi-Monitor Support** - Select which display to capture
|
||||
- ⚡ **Configurable FPS** - Adjust update rate (1-60 FPS)
|
||||
- 🎨 **Smart Calibration** - Map screen edges to LED positions
|
||||
- 🔌 **REST API** - Full control via HTTP endpoints
|
||||
- 🏠 **Home Assistant Integration** - Native HAOS support with entities
|
||||
- 🐳 **Docker Support** - Easy deployment with Docker Compose
|
||||
- 📊 **Real-time Metrics** - Monitor FPS, status, and performance
|
||||
### Screen Capture
|
||||
|
||||
- Multi-monitor support with per-target display selection
|
||||
- 5 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB)
|
||||
- Configurable capture regions, FPS, and border width
|
||||
- Capture templates for reusable configurations
|
||||
|
||||
### 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
|
||||
|
||||
### Server
|
||||
- Python 3.11 or higher
|
||||
- Windows, Linux, or macOS
|
||||
- WLED device on the same network
|
||||
|
||||
### Home Assistant Integration
|
||||
- Home Assistant OS 2023.1 or higher
|
||||
- Running WLED Screen Controller server
|
||||
- Python 3.11+ (or Docker)
|
||||
- A supported LED device on the local network or connected via USB
|
||||
- Windows for GPU-accelerated capture engines and audio capture; Linux/macOS supported via MSS
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Server Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
cd wled-screen-controller/server
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. **Run the server**
|
||||
```bash
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
4. **Access the API**
|
||||
- API: http://localhost:8080
|
||||
- Interactive docs: http://localhost:8080/docs
|
||||
|
||||
### Docker Installation
|
||||
|
||||
```bash
|
||||
cd server
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
cd wled-screen-controller/server
|
||||
|
||||
# Option A: Docker (recommended)
|
||||
docker-compose up -d
|
||||
|
||||
# Option B: Python
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# venv\Scripts\activate # Windows
|
||||
pip install .
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
# set PYTHONPATH=%CD%\src # Windows
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
Open `http://localhost:8080` to access the dashboard. The default API key for development is `development-key-change-in-production`.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including Docker manual builds and Home Assistant setup.
|
||||
|
||||
## Architecture
|
||||
|
||||
```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
|
||||
|
||||
Edit `server/config/default_config.yaml`:
|
||||
Edit `server/config/default_config.yaml` or use environment variables with the `LED_GRAB_` prefix:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
log_level: "INFO"
|
||||
|
||||
processing:
|
||||
default_fps: 30
|
||||
border_width: 10
|
||||
auth:
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
wled:
|
||||
timeout: 5
|
||||
retry_attempts: 3
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
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
|
||||
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
|
||||
}'
|
||||
```
|
||||
The server exposes a REST API (with Swagger docs at `/docs`) covering:
|
||||
|
||||
### 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
|
||||
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
|
||||
```
|
||||
All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
|
||||
|
||||
### Get Status
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/devices/{device_id}/state
|
||||
```
|
||||
|
||||
See [API Documentation](docs/API.md) for complete API reference.
|
||||
See [docs/API.md](docs/API.md) for the full reference.
|
||||
|
||||
## 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:
|
||||
```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}
|
||||
]
|
||||
}
|
||||
```
|
||||
See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
|
||||
|
||||
## Home Assistant Integration
|
||||
## Home Assistant
|
||||
|
||||
1. Copy `homeassistant/custom_components/wled_screen_controller` to your Home Assistant `custom_components` folder
|
||||
2. Restart Home Assistant
|
||||
3. Go to Settings → Integrations → Add Integration
|
||||
4. Search for "WLED Screen Controller"
|
||||
5. Enter your server URL
|
||||
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.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please open an issue or pull request.
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [WLED](https://github.com/Aircoookie/WLED) - Amazing LED control software
|
||||
- [FastAPI](https://fastapi.tiangolo.com/) - Modern Python web framework
|
||||
- [mss](https://python-mss.readthedocs.io/) - Fast screen capture library
|
||||
|
||||
## 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)
|
||||
- [WLED](https://github.com/Aircoookie/WLED) — LED control firmware
|
||||
- [FastAPI](https://fastapi.tiangolo.com/) — Python web framework
|
||||
- [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture
|
||||
|
||||
@@ -44,6 +44,8 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
enabled=device.enabled,
|
||||
baud_rate=device.baud_rate,
|
||||
auto_shutdown=device.auto_shutdown,
|
||||
send_latency_ms=device.send_latency_ms,
|
||||
rgbw=device.rgbw,
|
||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
@@ -116,6 +118,8 @@ async def create_device(
|
||||
device_type=device_type,
|
||||
baud_rate=device_data.baud_rate,
|
||||
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
|
||||
@@ -242,6 +246,8 @@ async def update_device(
|
||||
led_count=update_data.led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
auto_shutdown=update_data.auto_shutdown,
|
||||
send_latency_ms=update_data.send_latency_ms,
|
||||
rgbw=update_data.rgbw,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
|
||||
@@ -15,6 +15,8 @@ class DeviceCreate(BaseModel):
|
||||
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)")
|
||||
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):
|
||||
@@ -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)")
|
||||
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")
|
||||
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):
|
||||
@@ -93,6 +97,8 @@ class DeviceResponse(BaseModel):
|
||||
enabled: bool = Field(description="Whether device is enabled")
|
||||
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")
|
||||
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")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
@@ -279,5 +279,8 @@ def _register_builtin_providers():
|
||||
from wled_controller.core.devices.ambiled_provider import AmbiLEDDeviceProvider
|
||||
register_provider(AmbiLEDDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.mock_provider import MockDeviceProvider
|
||||
register_provider(MockDeviceProvider())
|
||||
|
||||
|
||||
_register_builtin_providers()
|
||||
|
||||
73
server/src/wled_controller/core/devices/mock_client.py
Normal file
73
server/src/wled_controller/core/devices/mock_client.py
Normal 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(),
|
||||
)
|
||||
43
server/src/wled_controller/core/devices/mock_provider.py
Normal file
43
server/src/wled_controller/core/devices/mock_provider.py
Normal 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
|
||||
@@ -129,6 +129,15 @@ class ProcessorManager:
|
||||
ds = self._devices.get(device_id)
|
||||
if ds is 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(
|
||||
device_id=ds.device_id,
|
||||
device_url=ds.device_url,
|
||||
@@ -137,6 +146,8 @@ class ProcessorManager:
|
||||
baud_rate=ds.baud_rate,
|
||||
software_brightness=ds.software_brightness,
|
||||
test_mode_active=ds.test_mode_active,
|
||||
send_latency_ms=send_latency_ms,
|
||||
rgbw=rgbw,
|
||||
)
|
||||
|
||||
# ===== EVENT SYSTEM (state change notifications) =====
|
||||
|
||||
@@ -69,6 +69,8 @@ class DeviceInfo:
|
||||
baud_rate: Optional[int] = None
|
||||
software_brightness: int = 255
|
||||
test_mode_active: bool = False
|
||||
send_latency_ms: int = 0
|
||||
rgbw: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -86,6 +86,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
device_info.device_type, device_info.device_url,
|
||||
use_ddp=True, led_count=device_info.led_count,
|
||||
baud_rate=device_info.baud_rate,
|
||||
send_latency_ms=device_info.send_latency_ms,
|
||||
rgbw=device_info.rgbw,
|
||||
)
|
||||
await self._led_client.connect()
|
||||
logger.info(
|
||||
|
||||
@@ -74,6 +74,10 @@ export function isSerialDevice(type) {
|
||||
return type === 'adalight' || type === 'ambiled';
|
||||
}
|
||||
|
||||
export function isMockDevice(type) {
|
||||
return type === 'mock';
|
||||
}
|
||||
|
||||
export function handle401Error() {
|
||||
if (!apiKey) return; // Already handled or no session
|
||||
localStorage.removeItem('wled_api_key');
|
||||
|
||||
@@ -95,13 +95,13 @@ export class CardSection {
|
||||
const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`);
|
||||
if (!header || !content) return;
|
||||
|
||||
header.addEventListener('click', (e) => {
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.cs-filter')) return;
|
||||
this._toggleCollapse(header, content);
|
||||
});
|
||||
|
||||
if (filterInput) {
|
||||
filterInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
filterInput.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
let timer = null;
|
||||
filterInput.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -653,9 +653,12 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
} else if (isAudio) {
|
||||
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
|
||||
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 = `
|
||||
<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>
|
||||
${source.audio_source_id ? `<span class="stream-card-prop" title="${t('color_strip.audio.source')}">🔊</span>` : ''}
|
||||
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
_discoveryScanRunning, set_discoveryScanRunning,
|
||||
_discoveryCache, set_discoveryCache,
|
||||
} 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 { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -23,6 +23,8 @@ class AddDeviceModal extends Modal {
|
||||
serialPort: document.getElementById('device-serial-port').value,
|
||||
ledCount: document.getElementById('device-led-count').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 ledCountGroup = document.getElementById('device-led-count-group');
|
||||
const discoverySection = document.getElementById('discovery-section');
|
||||
|
||||
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';
|
||||
urlInput.removeAttribute('required');
|
||||
serialGroup.style.display = '';
|
||||
serialSelect.setAttribute('required', '');
|
||||
ledCountGroup.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
|
||||
if (discoverySection) discoverySection.style.display = 'none';
|
||||
// Populate from cache or show placeholder (lazy-load on focus)
|
||||
@@ -68,6 +83,8 @@ export function onDeviceTypeChanged() {
|
||||
serialSelect.removeAttribute('required');
|
||||
ledCountGroup.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
|
||||
if (deviceType in _discoveryCache) {
|
||||
_renderDiscoveryList();
|
||||
@@ -287,12 +304,19 @@ export async function handleAddDevice(event) {
|
||||
|
||||
const name = document.getElementById('device-name').value.trim();
|
||||
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');
|
||||
|
||||
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.style.display = 'block';
|
||||
return;
|
||||
@@ -308,6 +332,12 @@ export async function handleAddDevice(event) {
|
||||
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
|
||||
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');
|
||||
if (lastTemplateId) {
|
||||
body.capture_template_id = lastTemplateId;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
} 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 { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -23,10 +23,16 @@ class DeviceSettingsModal extends Modal {
|
||||
state_check_interval: this.$('settings-health-interval').value,
|
||||
auto_shutdown: this.$('settings-auto-shutdown').checked,
|
||||
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() {
|
||||
if (isMockDevice(this.deviceType)) {
|
||||
const ledCount = this.$('settings-led-count')?.value || '60';
|
||||
return `mock://${ledCount}`;
|
||||
}
|
||||
if (isSerialDevice(this.deviceType)) {
|
||||
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-health-interval').value = 30;
|
||||
|
||||
const isMock = isMockDevice(device.device_type);
|
||||
const urlGroup = document.getElementById('settings-url-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';
|
||||
document.getElementById('settings-device-url').removeAttribute('required');
|
||||
serialGroup.style.display = '';
|
||||
@@ -202,6 +213,23 @@ export async function showSettings(deviceId) {
|
||||
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;
|
||||
settingsModal.snapshot();
|
||||
settingsModal.open();
|
||||
@@ -241,6 +269,12 @@ export async function saveDeviceSettings() {
|
||||
const baudVal = document.getElementById('settings-baud-rate').value;
|
||||
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}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
|
||||
@@ -122,6 +122,10 @@
|
||||
"device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)",
|
||||
"device.baud_rate": "Baud Rate:",
|
||||
"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.name": "Device Name:",
|
||||
"device.name.placeholder": "Living Room TV",
|
||||
|
||||
@@ -122,6 +122,10 @@
|
||||
"device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)",
|
||||
"device.baud_rate": "Скорость порта:",
|
||||
"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.name": "Имя Устройства:",
|
||||
"device.name.placeholder": "ТВ в Гостиной",
|
||||
|
||||
@@ -30,6 +30,8 @@ class Device:
|
||||
baud_rate: Optional[int] = None,
|
||||
software_brightness: int = 255,
|
||||
auto_shutdown: bool = False,
|
||||
send_latency_ms: int = 0,
|
||||
rgbw: bool = False,
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
@@ -42,6 +44,8 @@ class Device:
|
||||
self.baud_rate = baud_rate
|
||||
self.software_brightness = software_brightness
|
||||
self.auto_shutdown = auto_shutdown
|
||||
self.send_latency_ms = send_latency_ms
|
||||
self.rgbw = rgbw
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
# Preserved from old JSON for migration — not written back
|
||||
@@ -65,6 +69,10 @@ class Device:
|
||||
d["software_brightness"] = self.software_brightness
|
||||
if self.auto_shutdown:
|
||||
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
|
||||
|
||||
@classmethod
|
||||
@@ -84,6 +92,8 @@ class Device:
|
||||
baud_rate=data.get("baud_rate"),
|
||||
software_brightness=data.get("software_brightness", 255),
|
||||
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())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
)
|
||||
@@ -180,6 +190,8 @@ class DeviceStore:
|
||||
device_type: str = "wled",
|
||||
baud_rate: Optional[int] = None,
|
||||
auto_shutdown: bool = False,
|
||||
send_latency_ms: int = 0,
|
||||
rgbw: bool = False,
|
||||
) -> Device:
|
||||
"""Create a new device."""
|
||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||
@@ -192,6 +204,8 @@ class DeviceStore:
|
||||
device_type=device_type,
|
||||
baud_rate=baud_rate,
|
||||
auto_shutdown=auto_shutdown,
|
||||
send_latency_ms=send_latency_ms,
|
||||
rgbw=rgbw,
|
||||
)
|
||||
|
||||
self._devices[device_id] = device
|
||||
@@ -217,6 +231,8 @@ class DeviceStore:
|
||||
enabled: Optional[bool] = None,
|
||||
baud_rate: Optional[int] = None,
|
||||
auto_shutdown: Optional[bool] = None,
|
||||
send_latency_ms: Optional[int] = None,
|
||||
rgbw: Optional[bool] = None,
|
||||
) -> Device:
|
||||
"""Update device."""
|
||||
device = self._devices.get(device_id)
|
||||
@@ -235,6 +251,10 @@ class DeviceStore:
|
||||
device.baud_rate = baud_rate
|
||||
if auto_shutdown is not None:
|
||||
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()
|
||||
self.save()
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<option value="wled">WLED</option>
|
||||
<option value="adalight">Adalight</option>
|
||||
<option value="ambiled">AmbiLED</option>
|
||||
<option value="mock">Mock</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -78,6 +79,25 @@
|
||||
</select>
|
||||
<small id="baud-fps-hint" class="fps-hint" style="display:none"></small>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,26 @@
|
||||
<small id="settings-baud-fps-hint" class="fps-hint" style="display:none"></small>
|
||||
</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="label-row">
|
||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||
|
||||
Reference in New Issue
Block a user