Compare commits
4 Commits
e4c4301a7b
...
a39dc1b06a
| Author | SHA1 | Date | |
|---|---|---|---|
| a39dc1b06a | |||
| dc12452bcd | |||
| 0b89731d0c | |||
| 858a8e3ac2 |
289
README.md
289
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
|
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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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)
|
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) =====
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>` : ''}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "ТВ в Гостиной",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user