Add mock LED device type for testing without hardware

Virtual device with configurable LED count, RGB/RGBW mode, and simulated
send latency. Includes full provider/client implementation, API schema
support, and frontend add/settings modal integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 19:22:53 +03:00
parent dc12452bcd
commit a39dc1b06a
16 changed files with 291 additions and 9 deletions

View File

@@ -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

View File

@@ -15,6 +15,8 @@ class DeviceCreate(BaseModel):
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
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")

View File

@@ -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()

View File

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

View File

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

View File

@@ -129,6 +129,15 @@ class ProcessorManager:
ds = self._devices.get(device_id)
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) =====

View File

@@ -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

View File

@@ -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(

View File

@@ -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');

View File

@@ -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;

View File

@@ -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)

View File

@@ -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",

View File

@@ -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": "ТВ в Гостиной",

View File

@@ -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()

View File

@@ -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>

View File

@@ -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>