Fix autorestore logic and protocol badge per device type
Autorestore fixes: - Snapshot WLED state before connect() mutates it (lor, AudioReactive) - Gate restore on auto_shutdown setting (was unconditional) - Remove misleading auto_restore capability from serial provider - Default auto_shutdown to false for all new devices Protocol badge fixes: - Show correct protocol per device type (OpenRGB SDK, MQTT, WebSocket) - Was showing "Serial" for all non-WLED devices Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4
TODO.md
4
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
|
- [ ] `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
|
- 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
|
- 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
|
- 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
|
- 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")
|
- 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
|
- [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` **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
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ async def create_device(
|
|||||||
detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
|
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
|
auto_shutdown = device_data.auto_shutdown
|
||||||
if auto_shutdown is None:
|
if auto_shutdown is None:
|
||||||
auto_shutdown = device_type == "adalight"
|
auto_shutdown = False
|
||||||
|
|
||||||
# Create device in storage
|
# Create device in storage
|
||||||
device = store.create_device(
|
device = store.create_device(
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ class SerialDeviceProvider(LEDDeviceProvider):
|
|||||||
# power_control: can blank LEDs by sending all-black pixels
|
# power_control: can blank LEDs by sending all-black pixels
|
||||||
# brightness_control: software brightness (multiplies pixel values before sending)
|
# brightness_control: software brightness (multiplies pixel values before sending)
|
||||||
# health_check: serial port availability probe
|
# health_check: serial port availability probe
|
||||||
# auto_restore: blank LEDs when targets stop
|
return {"manual_led_count", "power_control", "brightness_control", "health_check"}
|
||||||
return {"manual_led_count", "power_control", "brightness_control", "health_check", "auto_restore"}
|
|
||||||
|
|
||||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
# Generic serial port health check — enumerate COM ports
|
# Generic serial port health check — enumerate COM ports
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class WLEDClient(LEDClient):
|
|||||||
self._client: Optional[httpx.AsyncClient] = None
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
self._ddp_client: Optional[DDPClient] = None
|
self._ddp_client: Optional[DDPClient] = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
self._pre_connect_state: Optional[dict] = None
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Establish connection to WLED device.
|
"""Establish connection to WLED device.
|
||||||
@@ -107,6 +108,9 @@ class WLEDClient(LEDClient):
|
|||||||
)
|
)
|
||||||
self.use_ddp = True
|
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
|
# Create DDP client if needed
|
||||||
if self.use_ddp:
|
if self.use_ddp:
|
||||||
self._ddp_client = DDPClient(self.host, rgbw=False)
|
self._ddp_client = DDPClient(self.host, rgbw=False)
|
||||||
@@ -465,7 +469,14 @@ class WLEDClient(LEDClient):
|
|||||||
# ===== LEDClient abstraction methods =====
|
# ===== LEDClient abstraction methods =====
|
||||||
|
|
||||||
async def snapshot_device_state(self) -> Optional[dict]:
|
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:
|
if not self._client:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ class ProcessorManager:
|
|||||||
send_latency_ms=send_latency_ms,
|
send_latency_ms=send_latency_ms,
|
||||||
rgbw=rgbw,
|
rgbw=rgbw,
|
||||||
zone_mode=ds.zone_mode,
|
zone_mode=ds.zone_mode,
|
||||||
|
auto_shutdown=ds.auto_shutdown,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== EVENT SYSTEM (state change notifications) =====
|
# ===== EVENT SYSTEM (state change notifications) =====
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class DeviceInfo:
|
|||||||
send_latency_ms: int = 0
|
send_latency_ms: int = 0
|
||||||
rgbw: bool = False
|
rgbw: bool = False
|
||||||
zone_mode: str = "combined"
|
zone_mode: str = "combined"
|
||||||
|
auto_shutdown: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -197,9 +197,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._task = None
|
self._task = None
|
||||||
await asyncio.sleep(0.05)
|
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:
|
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
|
self._device_state_before = None
|
||||||
|
|
||||||
# Close LED connection
|
# Close LED connection
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
|
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
|
||||||
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
|
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_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';
|
} from '../core/icons.js';
|
||||||
import { EntitySelect } from '../core/entity-palette.js';
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
@@ -163,6 +163,24 @@ class TargetEditorModal extends Modal {
|
|||||||
|
|
||||||
const targetEditorModal = new TargetEditorModal();
|
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;
|
let _targetNameManuallyEdited = false;
|
||||||
|
|
||||||
function _autoGenerateTargetName() {
|
function _autoGenerateTargetName() {
|
||||||
@@ -936,7 +954,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
|||||||
<div class="stream-card-props">
|
<div class="stream-card-props">
|
||||||
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
|
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
|
||||||
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
|
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
|
||||||
${device?.device_type === 'wled' || !device ? `<span class="stream-card-prop" title="${t('targets.protocol')}">${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${(target.protocol || 'ddp').toUpperCase()}</span>` : `<span class="stream-card-prop" title="${t('targets.protocol')}">${ICON_PLUG} ${t('targets.protocol.serial')}</span>`}
|
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
|
||||||
<span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
<span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
||||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||||
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</span>` : ''}
|
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</span>` : ''}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - Navigation: network-first with offline fallback
|
* - Navigation: network-first with offline fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = 'ledgrab-v21';
|
const CACHE_NAME = 'ledgrab-v22';
|
||||||
|
|
||||||
// Only pre-cache static assets (no auth required).
|
// Only pre-cache static assets (no auth required).
|
||||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||||
|
|||||||
Reference in New Issue
Block a user