diff --git a/TODO.md b/TODO.md index 050a4a5..9a0802f 100644 --- a/TODO.md +++ b/TODO.md @@ -33,7 +33,7 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - [ ] `P2` **WebSocket event bus** — Broadcast all state changes over a single WS channel - Complexity: low-medium — ProcessorManager already emits events; add a WS endpoint that fans out JSON events to connected clients - Impact: medium — enables real-time dashboards, mobile apps, and third-party integrations -- [ ] `P3` **Notification reactive** — Flash/pulse on OS notifications (optional app filter) +- [x] `P3` **Notification reactive** — Flash/pulse on OS notifications (optional app filter) - Complexity: large — OS-level notification listener (platform-specific: Win32 `WinToast`/`pystray`, macOS `pyobjc`); needs a new "effect source" type that triggers color pulses - Impact: low-medium — fun but niche; platform-dependent maintenance burden @@ -60,4 +60,4 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming") - [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest - [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default -- [ ] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards +- [x] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index e107660..755ba74 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -110,10 +110,10 @@ async def create_device( detail=f"Failed to connect to {device_type} device at {device_url}: {e}" ) - # Resolve auto_shutdown default: True for adalight, False otherwise + # Resolve auto_shutdown default: False for all types auto_shutdown = device_data.auto_shutdown if auto_shutdown is None: - auto_shutdown = device_type == "adalight" + auto_shutdown = False # Create device in storage device = store.create_device( diff --git a/server/src/wled_controller/core/devices/serial_provider.py b/server/src/wled_controller/core/devices/serial_provider.py index c5df236..53fe5d8 100644 --- a/server/src/wled_controller/core/devices/serial_provider.py +++ b/server/src/wled_controller/core/devices/serial_provider.py @@ -29,8 +29,7 @@ class SerialDeviceProvider(LEDDeviceProvider): # power_control: can blank LEDs by sending all-black pixels # brightness_control: software brightness (multiplies pixel values before sending) # health_check: serial port availability probe - # auto_restore: blank LEDs when targets stop - return {"manual_led_count", "power_control", "brightness_control", "health_check", "auto_restore"} + return {"manual_led_count", "power_control", "brightness_control", "health_check"} async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: # Generic serial port health check — enumerate COM ports diff --git a/server/src/wled_controller/core/devices/wled_client.py b/server/src/wled_controller/core/devices/wled_client.py index d925bc9..b74d178 100644 --- a/server/src/wled_controller/core/devices/wled_client.py +++ b/server/src/wled_controller/core/devices/wled_client.py @@ -82,6 +82,7 @@ class WLEDClient(LEDClient): self._client: Optional[httpx.AsyncClient] = None self._ddp_client: Optional[DDPClient] = None self._connected = False + self._pre_connect_state: Optional[dict] = None async def connect(self) -> bool: """Establish connection to WLED device. @@ -107,6 +108,9 @@ class WLEDClient(LEDClient): ) self.use_ddp = True + # Snapshot device state BEFORE any mutations (for auto-restore) + self._pre_connect_state = await self.snapshot_device_state() + # Create DDP client if needed if self.use_ddp: self._ddp_client = DDPClient(self.host, rgbw=False) @@ -465,7 +469,14 @@ class WLEDClient(LEDClient): # ===== LEDClient abstraction methods ===== async def snapshot_device_state(self) -> Optional[dict]: - """Snapshot WLED state before streaming (on, lor, AudioReactive).""" + """Snapshot WLED state (on, lor, AudioReactive). + + If connect() already captured a pre-mutation snapshot, returns that + instead of the current (potentially already-modified) state. + """ + # Return pre-connect snapshot if available (captured before DDP setup) + if hasattr(self, '_pre_connect_state') and self._pre_connect_state is not None: + return self._pre_connect_state if not self._client: return None try: diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index d9b3dd8..5345273 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -165,6 +165,7 @@ class ProcessorManager: send_latency_ms=send_latency_ms, rgbw=rgbw, zone_mode=ds.zone_mode, + auto_shutdown=ds.auto_shutdown, ) # ===== 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 9bfbee7..1e770a1 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -75,6 +75,7 @@ class DeviceInfo: send_latency_ms: int = 0 rgbw: bool = False zone_mode: str = "combined" + auto_shutdown: 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 e3cddcf..59401c4 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -197,9 +197,11 @@ class WledTargetProcessor(TargetProcessor): self._task = None await asyncio.sleep(0.05) - # Restore device state + # Restore device state (only if auto_shutdown is enabled) if self._led_client and self._device_state_before: - await self._led_client.restore_device_state(self._device_state_before) + device_info = self._ctx.get_device_info(self._device_id) + if device_info and device_info.auto_shutdown: + await self._led_client.restore_device_state(self._device_state_before) self._device_state_before = None # Close LED connection diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 803f4d0..3e3c639 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -24,7 +24,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, - ICON_WARNING, + ICON_WARNING, ICON_PALETTE, ICON_WRENCH, } from '../core/icons.js'; import { EntitySelect } from '../core/entity-palette.js'; import { wrapCard } from '../core/card-colors.js'; @@ -163,6 +163,24 @@ class TargetEditorModal extends Modal { const targetEditorModal = new TargetEditorModal(); +function _protocolBadge(device, target) { + const dt = device?.device_type; + if (!dt || dt === 'wled') { + const proto = target.protocol === 'http' ? 'HTTP' : 'DDP'; + return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`; + } + const map = { + openrgb: [ICON_PALETTE, 'OpenRGB SDK'], + adalight: [ICON_PLUG, t('targets.protocol.serial')], + ambiled: [ICON_PLUG, t('targets.protocol.serial')], + mqtt: [ICON_GLOBE, 'MQTT'], + ws: [ICON_GLOBE, 'WebSocket'], + mock: [ICON_WRENCH, 'Mock'], + }; + const [icon, label] = map[dt] || [ICON_PLUG, dt]; + return `${icon} ${label}`; +} + let _targetNameManuallyEdited = false; function _autoGenerateTargetName() { @@ -936,7 +954,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo