diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index a8028b6..aac6b57 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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 diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index dd2f9df..6bdb4ea 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -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") diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index 8267cf9..07e6016 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -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() diff --git a/server/src/wled_controller/core/devices/mock_client.py b/server/src/wled_controller/core/devices/mock_client.py new file mode 100644 index 0000000..a0e8519 --- /dev/null +++ b/server/src/wled_controller/core/devices/mock_client.py @@ -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(), + ) diff --git a/server/src/wled_controller/core/devices/mock_provider.py b/server/src/wled_controller/core/devices/mock_provider.py new file mode 100644 index 0000000..e81ead7 --- /dev/null +++ b/server/src/wled_controller/core/devices/mock_provider.py @@ -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 diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 32b2bdc..53885f1 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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) ===== diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 85c424d..8ab6c07 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -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 diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 6ac1e5b..6cacba3 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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( diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js index 72eaa5e..00df93b 100644 --- a/server/src/wled_controller/static/js/core/api.js +++ b/server/src/wled_controller/static/js/core/api.js @@ -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'); diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index 42dbbe7..4202d01 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -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; diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index b524efe..3809b2d 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -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) diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 150bebf..03071de 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 7f1017a..6451f41 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "ТВ в Гостиной", diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index 2fca70b..0f96c90 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -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() diff --git a/server/src/wled_controller/templates/modals/add-device.html b/server/src/wled_controller/templates/modals/add-device.html index 8208f61..c0b5c42 100644 --- a/server/src/wled_controller/templates/modals/add-device.html +++ b/server/src/wled_controller/templates/modals/add-device.html @@ -30,6 +30,7 @@ +
@@ -78,6 +79,25 @@
+ + diff --git a/server/src/wled_controller/templates/modals/device-settings.html b/server/src/wled_controller/templates/modals/device-settings.html index 8e63942..a3e6827 100644 --- a/server/src/wled_controller/templates/modals/device-settings.html +++ b/server/src/wled_controller/templates/modals/device-settings.html @@ -58,6 +58,26 @@ + + +