diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py
index bc89e1b..dc924e6 100644
--- a/server/src/ledgrab/api/routes/output_targets.py
+++ b/server/src/ledgrab/api/routes/output_targets.py
@@ -54,6 +54,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
protocol=target.protocol,
description=target.description,
tags=target.tags,
+ icon=getattr(target, "icon", "") or "",
+ icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at,
updated_at=target.updated_at,
)
@@ -82,8 +84,11 @@ def _ha_light_target_to_response(
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
+ stop_action=target.stop_action,
description=target.description,
tags=target.tags,
+ icon=getattr(target, "icon", "") or "",
+ icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at,
updated_at=target.updated_at,
)
@@ -165,6 +170,7 @@ async def create_target(
update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5),
+ stop_action=getattr(data, "stop_action", "none"),
)
# Register in processor manager
@@ -283,11 +289,14 @@ async def update_target(
protocol=getattr(data, "protocol", None),
description=data.description,
tags=data.tags,
+ icon=data.icon,
+ icon_color=data.icon_color,
ha_source_id=getattr(data, "ha_source_id", None),
ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", None),
+ stop_action=getattr(data, "stop_action", None),
)
# Sync processor manager (run in thread — css release/acquire can block)
@@ -301,6 +310,7 @@ async def update_target(
transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None)
+ stop_action = getattr(data, "stop_action", None)
try:
await asyncio.to_thread(
@@ -317,6 +327,7 @@ async def update_target(
or color_tolerance is not None
or ha_light_mappings_raw is not None
or brightness is not None
+ or stop_action is not None
),
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
diff --git a/server/src/ledgrab/api/schemas/output_targets.py b/server/src/ledgrab/api/schemas/output_targets.py
index fd88a75..3040219 100644
--- a/server/src/ledgrab/api/schemas/output_targets.py
+++ b/server/src/ledgrab/api/schemas/output_targets.py
@@ -55,6 +55,8 @@ class _OutputTargetResponseBase(BaseModel):
name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
+ icon: str = Field(default="", description="Custom icon id from the curated icon library")
+ icon_color: str = Field(default="", description="Optional CSS color override for the icon")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -98,6 +100,11 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
+ stop_action: Literal["none", "turn_off", "restore"] = Field(
+ default="none",
+ description="What to do with mapped lights when the target stops: "
+ "'none' (leave as-is), 'turn_off', or 'restore' (revert to state captured at start).",
+ )
OutputTargetResponse = Annotated[
@@ -119,6 +126,12 @@ class _OutputTargetCreateBase(BaseModel):
name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
+ icon: Optional[str] = Field(
+ None, max_length=64, description="Custom icon id from the curated icon library"
+ )
+ icon_color: Optional[str] = Field(
+ None, max_length=32, description="Optional CSS color override for the icon"
+ )
class LedOutputTargetCreate(_OutputTargetCreateBase):
@@ -180,6 +193,10 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
+ stop_action: Literal["none", "turn_off", "restore"] = Field(
+ default="none",
+ description="Finalization on stop: 'none', 'turn_off', or 'restore'.",
+ )
OutputTargetCreate = Annotated[
@@ -201,6 +218,16 @@ class _OutputTargetUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
+ icon: Optional[str] = Field(
+ None,
+ max_length=64,
+ description="Custom icon id; pass empty string to clear and inherit from device.",
+ )
+ icon_color: Optional[str] = Field(
+ None,
+ max_length=32,
+ description="Optional CSS color override for the icon; empty string clears.",
+ )
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
@@ -246,6 +273,9 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
+ stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field(
+ None, description="Finalization on stop: 'none', 'turn_off', or 'restore'."
+ )
OutputTargetUpdate = Annotated[
diff --git a/server/src/ledgrab/core/processing/ha_light_target_processor.py b/server/src/ledgrab/core/processing/ha_light_target_processor.py
index 576ea47..cd1f9e4 100644
--- a/server/src/ledgrab/core/processing/ha_light_target_processor.py
+++ b/server/src/ledgrab/core/processing/ha_light_target_processor.py
@@ -35,6 +35,7 @@ class HALightTargetProcessor(TargetProcessor):
transition=None,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
+ stop_action: str = "none",
ctx: Optional[TargetContext] = None,
):
from ledgrab.storage.bindable import BindableFloat, bfloat
@@ -56,6 +57,9 @@ class HALightTargetProcessor(TargetProcessor):
self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0)))
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._color_tolerance = int(bfloat(color_tolerance, 5.0))
+ self._stop_action = (
+ stop_action if stop_action in ("none", "turn_off", "restore") else "none"
+ )
# Runtime state
self._css_stream = None
@@ -64,6 +68,8 @@ class HALightTargetProcessor(TargetProcessor):
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
self._previous_on: Dict[str, bool] = {} # track on/off state per entity
self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {}
+ # Snapshot of entity states captured at start() — used by "restore" stop action
+ self._captured_states: Dict[str, Any] = {}
self._ws_clients: List[Any] = []
self._start_time: Optional[float] = None
@@ -104,6 +110,10 @@ class HALightTargetProcessor(TargetProcessor):
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
self._value_stream = None
+ # Capture initial entity states for "restore" stop action.
+ # We always capture (cheap) so changing stop_action while running still works.
+ self._captured_states = self._snapshot_mapped_entity_states()
+
self._is_running = True
self._start_time = time.monotonic()
self._task = asyncio.create_task(self._processing_loop())
@@ -119,6 +129,14 @@ class HALightTargetProcessor(TargetProcessor):
pass
self._task = None
+ # Run finalization (turn_off / restore) before releasing the HA runtime.
+ try:
+ await self._apply_stop_action()
+ except Exception as e:
+ logger.warning(
+ f"HA light {self._target_id}: stop_action '{self._stop_action}' failed: {e}"
+ )
+
# Release CSS stream
if self._css_stream and self._ctx.color_strip_stream_manager:
try:
@@ -148,6 +166,7 @@ class HALightTargetProcessor(TargetProcessor):
self._previous_colors.clear()
self._previous_on.clear()
self._latest_entity_colors.clear()
+ self._captured_states.clear()
self._ws_clients.clear()
logger.info(f"HA light target stopped: {self._target_id}")
@@ -177,6 +196,10 @@ class HALightTargetProcessor(TargetProcessor):
self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
if "light_mappings" in settings:
self._light_mappings = settings["light_mappings"]
+ if "stop_action" in settings:
+ sa = settings["stop_action"]
+ if sa in ("none", "turn_off", "restore"):
+ self._stop_action = sa
def update_css_source(self, color_strip_source_id: str) -> None:
"""Hot-swap the CSS stream."""
@@ -378,3 +401,103 @@ class HALightTargetProcessor(TargetProcessor):
dead.append(ws)
for ws in dead:
self._ws_clients.remove(ws)
+
+ # ── Stop-action finalization ──
+
+ def _snapshot_mapped_entity_states(self) -> Dict[str, Any]:
+ """Capture current state of every mapped entity from the HA cache."""
+ if not self._ha_runtime:
+ return {}
+ snap: Dict[str, Any] = {}
+ for mapping in self._light_mappings:
+ eid = mapping.entity_id
+ if not eid:
+ continue
+ state = self._ha_runtime.get_state(eid)
+ if state is not None:
+ snap[eid] = state
+ return snap
+
+ async def _apply_stop_action(self) -> None:
+ """Run the configured finalization on stop."""
+ if self._stop_action == "none":
+ return
+ if not self._ha_runtime or not self._ha_runtime.is_connected:
+ logger.info(
+ f"HA light {self._target_id}: skipping stop_action "
+ f"'{self._stop_action}' — HA not connected"
+ )
+ return
+
+ # Unique entity ids (a target may map the same entity twice in theory)
+ entity_ids = []
+ seen = set()
+ for mapping in self._light_mappings:
+ eid = mapping.entity_id
+ if eid and eid not in seen:
+ seen.add(eid)
+ entity_ids.append(eid)
+
+ if not entity_ids:
+ return
+
+ if self._stop_action == "turn_off":
+ for eid in entity_ids:
+ await self._ha_runtime.call_service(
+ domain="light",
+ service="turn_off",
+ service_data={},
+ target={"entity_id": eid},
+ )
+ return
+
+ if self._stop_action == "restore":
+ for eid in entity_ids:
+ state = self._captured_states.get(eid)
+ if state is None:
+ continue
+ await self._restore_entity(eid, state)
+
+ async def _restore_entity(self, entity_id: str, state: Any) -> None:
+ """Restore one light entity to a captured HAEntityState."""
+ if state.state == "off":
+ await self._ha_runtime.call_service(
+ domain="light",
+ service="turn_off",
+ service_data={},
+ target={"entity_id": entity_id},
+ )
+ return
+
+ if state.state != "on":
+ # unknown / unavailable — best effort: do nothing
+ return
+
+ attrs = state.attributes or {}
+ service_data: Dict[str, Any] = {}
+
+ # Color: prefer rgb_color, then hs_color, then color_temp, then nothing
+ rgb = attrs.get("rgb_color")
+ if isinstance(rgb, (list, tuple)) and len(rgb) >= 3:
+ service_data["rgb_color"] = [int(rgb[0]), int(rgb[1]), int(rgb[2])]
+ else:
+ hs = attrs.get("hs_color")
+ color_temp = attrs.get("color_temp")
+ color_temp_kelvin = attrs.get("color_temp_kelvin")
+ if isinstance(hs, (list, tuple)) and len(hs) >= 2:
+ service_data["hs_color"] = [float(hs[0]), float(hs[1])]
+ elif color_temp_kelvin is not None:
+ service_data["color_temp_kelvin"] = int(color_temp_kelvin)
+ elif color_temp is not None:
+ service_data["color_temp"] = int(color_temp)
+
+ brightness = attrs.get("brightness")
+ if brightness is not None:
+ service_data["brightness"] = int(brightness)
+
+ await self._ha_runtime.call_service(
+ domain="light",
+ service="turn_on",
+ service_data=service_data,
+ target={"entity_id": entity_id},
+ )
diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py
index b4dda57..b5a732f 100644
--- a/server/src/ledgrab/core/processing/processor_manager.py
+++ b/server/src/ledgrab/core/processing/processor_manager.py
@@ -437,6 +437,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
transition=None,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
+ stop_action: str = "none",
) -> None:
"""Register a Home Assistant light target processor."""
if target_id in self._processors:
@@ -454,6 +455,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
+ stop_action=stop_action,
ctx=self._build_context(),
)
self._processors[target_id] = proc
diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css
index 830b2ab..7d8eef5 100644
--- a/server/src/ledgrab/static/css/cards.css
+++ b/server/src/ledgrab/static/css/cards.css
@@ -2233,6 +2233,13 @@ ul.section-tip li {
color: var(--lux-ink-mute, var(--text-secondary));
min-width: 42px;
}
+.mod-fader__lane {
+ flex: 1;
+ position: relative;
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
.mod-fader__track {
flex: 1;
position: relative;
@@ -2254,13 +2261,12 @@ ul.section-tip li {
var(--ch));
box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 60%, transparent);
}
-/* The slider input lays flat over the track, transparent, so the user
- drags the visual track without seeing the native control. */
-.mod-fader { position: relative; }
+/* The slider input overlays the visible track exactly — same flex slot,
+ so its hit-zone aligns to the fill regardless of label/value width. */
.mod-fader__slider {
position: absolute;
- left: 52px;
- right: 50px; /* between label and value cells */
+ left: 0;
+ right: 0;
top: 50%;
transform: translateY(-50%);
height: 18px;
diff --git a/server/src/ledgrab/static/css/dashboard-customize.css b/server/src/ledgrab/static/css/dashboard-customize.css
index 8105eba..9dadedf 100644
--- a/server/src/ledgrab/static/css/dashboard-customize.css
+++ b/server/src/ledgrab/static/css/dashboard-customize.css
@@ -204,9 +204,17 @@
transform: scale(0.98);
}
-.dash-cust-row.is-drop-target {
- border-color: var(--ch-signal, var(--primary-color));
- background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent);
+.dash-cust-row.is-drop-target-before,
+.dash-cust-row.is-drop-target-after {
+ background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 6%, transparent);
+}
+
+.dash-cust-row.is-drop-target-before {
+ box-shadow: 0 -2px 0 0 var(--ch-signal, var(--primary-color));
+}
+
+.dash-cust-row.is-drop-target-after {
+ box-shadow: 0 2px 0 0 var(--ch-signal, var(--primary-color));
}
.dash-cust-row-fixed {
diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css
index a9a1ce9..a03562d 100644
--- a/server/src/ledgrab/static/css/dashboard.css
+++ b/server/src/ledgrab/static/css/dashboard.css
@@ -336,8 +336,13 @@
/* ── Custom card icon plate ──────────────────────────────────────
A 44x44 instrument-panel face plate at the leading edge of the
head row. Channel-tinted; clickable to open the icon picker.
- Renders only when ModHeadOpts.iconHtml is supplied. */
-.mod-head--with-icon { align-items: stretch; }
+ Renders only when ModHeadOpts.iconHtml is supplied.
+
+ The plate sits at the top-left of the head row at its own fixed
+ size. We deliberately do NOT set align-items:stretch on the head —
+ that would force sibling slots (LED bezel, kebab) to inherit the
+ plate's height. Instead each slot keeps its natural compact size.
+*/
.mod-icon {
--plate-size: 44px;
diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css
index 98cc0ff..822b54b 100644
--- a/server/src/ledgrab/static/css/modal.css
+++ b/server/src/ledgrab/static/css/modal.css
@@ -5385,6 +5385,19 @@ body.composite-layer-dragging .composite-layer-drag-handle {
background: transparent !important;
color: var(--lux-ink-mute, var(--text-secondary)) !important;
}
+.icon-picker-preview.is-inherited svg {
+ opacity: 0.7;
+}
+.icon-picker-sub.is-inherited {
+ color: var(--ch-cyan, var(--primary-color));
+ font-style: italic;
+}
+.icon-picker-sub.is-inherited::before {
+ content: '↳';
+ margin-right: 4px;
+ font-style: normal;
+ opacity: 0.85;
+}
.icon-picker-meta { flex: 1; min-width: 0; }
.icon-picker-eyebrow {
diff --git a/server/src/ledgrab/static/js/core/mod-card.ts b/server/src/ledgrab/static/js/core/mod-card.ts
index 1e950d0..c7dcefc 100644
--- a/server/src/ledgrab/static/js/core/mod-card.ts
+++ b/server/src/ledgrab/static/js/core/mod-card.ts
@@ -369,8 +369,10 @@ export function renderModFader(f: ModFaderOpts): string {
const disabledAttr = f.disabled ? ' disabled' : '';
return `
${escapeHtml(f.label)}
-
-
+
${f.value}
`;
}
diff --git a/server/src/ledgrab/static/js/features/color-strips/index.ts b/server/src/ledgrab/static/js/features/color-strips/index.ts
index 5175954..7a2e4f1 100644
--- a/server/src/ledgrab/static/js/features/color-strips/index.ts
+++ b/server/src/ledgrab/static/js/features/color-strips/index.ts
@@ -1272,6 +1272,7 @@ const _typeHandlers: Record any; reset: (...
picture: {
load(css, sourceSelect) {
sourceSelect.value = css.picture_source_id || '';
+ if (_cssPictureSourceEntitySelect) _cssPictureSourceEntitySelect.setValue(sourceSelect.value);
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
_ensureSmoothingWidget().setValue(css.smoothing);
diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts
index ff2e38b..ade25eb 100644
--- a/server/src/ledgrab/static/js/features/dashboard-customize.ts
+++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts
@@ -89,6 +89,15 @@ const PERF_CELL_LABEL_KEYS: Record = {
};
let _unsubscribe: (() => void) | null = null;
+// True while the user is mid-drag in the panel. While set, layout-change
+// notifications are deferred — re-rendering would replace the dragged DOM
+// node and abort the drag. The flag is cleared on `dragend`.
+let _activeDrag = false;
+let _renderDeferred = false;
+// After an arrow-button move, restore focus on the same arrow once the
+// panel re-renders. Without this, every press destroys the focus and the
+// user has to re-aim for each click.
+let _focusAfterRender: string | null = null;
export function openDashboardCustomize(): void {
let panel = document.getElementById(PANEL_ID);
@@ -101,7 +110,10 @@ export function openDashboardCustomize(): void {
if (backdrop) backdrop.classList.add('is-open');
_renderPanelBody();
if (!_unsubscribe) {
- _unsubscribe = subscribeDashboardLayout(() => _renderPanelBody());
+ _unsubscribe = subscribeDashboardLayout(() => {
+ if (_activeDrag) { _renderDeferred = true; return; }
+ _renderPanelBody();
+ });
}
}
@@ -161,6 +173,11 @@ function _renderPanelBody(): void {
${_renderActions()}
`;
_bindHandlers(body);
+ if (_focusAfterRender) {
+ const el = body.querySelector(_focusAfterRender);
+ el?.focus();
+ _focusAfterRender = null;
+ }
}
// ── Sub-renderers ────────────────────────────────────────────────────────
@@ -398,6 +415,7 @@ function _bindHandlers(root: HTMLElement): void {
btn.addEventListener('click', () => {
const key = btn.dataset.sectionKey!;
const dir = btn.dataset.move as 'up' | 'down';
+ _focusAfterRender = `[data-section-key="${key}"][data-move="${dir}"]`;
_moveSection(key, dir);
});
});
@@ -444,6 +462,7 @@ function _bindHandlers(root: HTMLElement): void {
btn.addEventListener('click', () => {
const key = btn.dataset.cellKey!;
const dir = btn.dataset.cellMove as 'up' | 'down';
+ _focusAfterRender = `[data-cell-key="${key}"][data-cell-move="${dir}"]`;
_movePerfCell(key, dir);
});
});
@@ -503,6 +522,21 @@ function _movePerfCell(key: string, dir: 'up' | 'down'): void {
}
// ── Hand-rolled drag-and-drop sort ──────────────────────────────────────
+//
+// Uses event delegation (one listener per list, not per row) so re-renders
+// don't accumulate listeners and the cost is constant in row count.
+//
+// Insertion semantics: the dragged item lands BEFORE or AFTER the row
+// under the cursor based on whether the cursor is in the upper or lower
+// half of that row. A coloured insertion line shows where it will land.
+//
+// Drag is suppressed when the press starts on an interactive child
+// (button/select/input) so embedded controls — eye toggle, density
+// buttons, ↑/↓ arrows — keep working.
+//
+// While a drag is active, `_activeDrag` is set so the layout subscriber
+// defers re-rendering. Without that, a debounced PUT echo mid-drag would
+// replace the dragged DOM node and abort the gesture.
function _bindDragSort(
root: HTMLElement,
@@ -512,42 +546,159 @@ function _bindDragSort(
): void {
const list = root.querySelector(listSelector);
if (!list) return;
- let dragKey: string | null = null;
- list.querySelectorAll('.dash-cust-row-drag').forEach(row => {
- row.addEventListener('dragstart', (e) => {
- dragKey = row.getAttribute(keyAttr);
- row.classList.add('is-dragging');
- if (e.dataTransfer) {
- e.dataTransfer.effectAllowed = 'move';
- // Required by Firefox to enable drag.
- e.dataTransfer.setData('text/plain', dragKey || '');
+ let dragKey: string | null = null;
+ let dragRow: HTMLElement | null = null;
+ let lastIndicatorRow: HTMLElement | null = null;
+ let lastIndicatorPos: 'before' | 'after' | null = null;
+ let autoScrollRaf: number | null = null;
+ let autoScrollDir: -1 | 0 | 1 = 0;
+
+ const scroller = list.closest('.dash-cust-body');
+
+ const clearIndicator = (): void => {
+ if (lastIndicatorRow) {
+ lastIndicatorRow.classList.remove('is-drop-target-before', 'is-drop-target-after');
+ }
+ lastIndicatorRow = null;
+ lastIndicatorPos = null;
+ };
+
+ const stopAutoScroll = (): void => {
+ if (autoScrollRaf !== null) {
+ cancelAnimationFrame(autoScrollRaf);
+ autoScrollRaf = null;
+ }
+ autoScrollDir = 0;
+ };
+
+ const tickAutoScroll = (): void => {
+ autoScrollRaf = null;
+ if (!scroller || autoScrollDir === 0) return;
+ scroller.scrollTop += autoScrollDir * 12;
+ autoScrollRaf = requestAnimationFrame(tickAutoScroll);
+ };
+
+ const updateAutoScroll = (clientY: number): void => {
+ if (!scroller) return;
+ const rect = scroller.getBoundingClientRect();
+ const edge = 40;
+ let dir: -1 | 0 | 1 = 0;
+ if (clientY < rect.top + edge) dir = -1;
+ else if (clientY > rect.bottom - edge) dir = 1;
+ if (dir !== autoScrollDir) {
+ autoScrollDir = dir;
+ if (dir !== 0 && autoScrollRaf === null) {
+ autoScrollRaf = requestAnimationFrame(tickAutoScroll);
}
- });
- row.addEventListener('dragend', () => {
- row.classList.remove('is-dragging');
- dragKey = null;
- list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target'));
- });
- row.addEventListener('dragover', (e) => {
- if (!dragKey) return;
- e.preventDefault();
- list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target'));
- row.classList.add('is-drop-target');
- });
- row.addEventListener('drop', (e) => {
- e.preventDefault();
- const targetKey = row.getAttribute(keyAttr);
- if (!dragKey || !targetKey || dragKey === targetKey) return;
- const allRows = Array.from(list.querySelectorAll('.dash-cust-row-drag'));
- const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || '');
- const fromIdx = orderedKeys.indexOf(dragKey);
- const toIdx = orderedKeys.indexOf(targetKey);
- if (fromIdx < 0 || toIdx < 0) return;
- const [moved] = orderedKeys.splice(fromIdx, 1);
- orderedKeys.splice(toIdx, 0, moved);
- onReorder(orderedKeys.filter(Boolean));
- });
+ }
+ };
+
+ const getRowFrom = (target: EventTarget | null): HTMLElement | null => {
+ const el = target as HTMLElement | null;
+ if (!el) return null;
+ const row = el.closest('.dash-cust-row-drag');
+ return row && list.contains(row) ? row : null;
+ };
+
+ list.addEventListener('dragstart', (e) => {
+ // Don't start drag from interactive controls inside the row.
+ const interactive = (e.target as HTMLElement | null)
+ ?.closest('button, select, input, textarea, a');
+ if (interactive) { e.preventDefault(); return; }
+
+ const row = getRowFrom(e.target);
+ if (!row) return;
+
+ dragKey = row.getAttribute(keyAttr);
+ dragRow = row;
+ row.classList.add('is-dragging');
+ _activeDrag = true;
+ if (e.dataTransfer) {
+ e.dataTransfer.effectAllowed = 'move';
+ // Required by Firefox to enable drag.
+ e.dataTransfer.setData('text/plain', dragKey || '');
+ }
+ });
+
+ list.addEventListener('dragend', () => {
+ if (dragRow) dragRow.classList.remove('is-dragging');
+ dragRow = null;
+ dragKey = null;
+ clearIndicator();
+ stopAutoScroll();
+ _activeDrag = false;
+ // Run any layout update that was deferred during the drag.
+ if (_renderDeferred) {
+ _renderDeferred = false;
+ _renderPanelBody();
+ }
+ });
+
+ list.addEventListener('dragover', (e) => {
+ if (!dragKey) return;
+ const row = getRowFrom(e.target);
+ if (!row) return;
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
+
+ const rect = row.getBoundingClientRect();
+ const pos: 'before' | 'after' = (e.clientY < rect.top + rect.height / 2)
+ ? 'before' : 'after';
+
+ if (row !== lastIndicatorRow || pos !== lastIndicatorPos) {
+ clearIndicator();
+ row.classList.add(pos === 'before' ? 'is-drop-target-before' : 'is-drop-target-after');
+ lastIndicatorRow = row;
+ lastIndicatorPos = pos;
+ }
+ updateAutoScroll(e.clientY);
+ });
+
+ list.addEventListener('dragleave', (e) => {
+ // Clear only when leaving the list entirely, not when crossing
+ // between child rows.
+ const related = e.relatedTarget as Node | null;
+ if (!related || !list.contains(related)) {
+ clearIndicator();
+ stopAutoScroll();
+ }
+ });
+
+ list.addEventListener('drop', (e) => {
+ e.preventDefault();
+ const row = getRowFrom(e.target);
+ if (!dragKey || !row) {
+ clearIndicator();
+ stopAutoScroll();
+ return;
+ }
+ const targetKey = row.getAttribute(keyAttr);
+ if (!targetKey || dragKey === targetKey) {
+ clearIndicator();
+ stopAutoScroll();
+ return;
+ }
+ const rect = row.getBoundingClientRect();
+ const pos: 'before' | 'after' = (e.clientY < rect.top + rect.height / 2)
+ ? 'before' : 'after';
+
+ const allRows = Array.from(list.querySelectorAll('.dash-cust-row-drag'));
+ const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || '').filter(Boolean);
+ const fromIdx = orderedKeys.indexOf(dragKey);
+ if (fromIdx < 0) { clearIndicator(); stopAutoScroll(); return; }
+
+ // Remove the dragged key first, then recompute the target's index
+ // because the splice may have shifted it.
+ orderedKeys.splice(fromIdx, 1);
+ const targetIdx = orderedKeys.indexOf(targetKey);
+ if (targetIdx < 0) { clearIndicator(); stopAutoScroll(); return; }
+ const insertAt = pos === 'before' ? targetIdx : targetIdx + 1;
+ orderedKeys.splice(insertAt, 0, dragKey);
+
+ clearIndicator();
+ stopAutoScroll();
+ onReorder(orderedKeys);
});
}
diff --git a/server/src/ledgrab/static/js/features/ha-light-targets.ts b/server/src/ledgrab/static/js/features/ha-light-targets.ts
index 9cf2f6d..30a6366 100644
--- a/server/src/ledgrab/static/js/features/ha-light-targets.ts
+++ b/server/src/ledgrab/static/js/features/ha-light-targets.ts
@@ -11,6 +11,7 @@ import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
import { ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_OK, ICON_WARNING, ICON_CLOCK, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
+import { IconSelect } from '../core/icon-select.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -34,6 +35,7 @@ let _updateRateWidget: BindableScalarWidget | null = null;
let _transitionWidget: BindableScalarWidget | null = null;
let _colorToleranceWidget: BindableScalarWidget | null = null;
let _minBrightnessThresholdWidget: BindableScalarWidget | null = null;
+let _stopActionIconSelect: IconSelect | null = null;
class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); }
@@ -47,6 +49,7 @@ class HALightEditorModal extends Modal {
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; }
+ if (_stopActionIconSelect) { _stopActionIconSelect.destroy(); _stopActionIconSelect = null; }
_destroyMappingEntitySelects();
}
@@ -60,6 +63,7 @@ class HALightEditorModal extends Modal {
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0',
+ stop_action: (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value,
mappings: _getMappingsJSON(),
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
};
@@ -271,6 +275,22 @@ function _ensureColorToleranceWidget(): BindableScalarWidget {
return _colorToleranceWidget;
}
+function _stopActionItems() {
+ return [
+ { value: 'none', icon: _icon(P.circleOff), label: t('ha_light.stop_action.none'), desc: t('ha_light.stop_action.none.desc') },
+ { value: 'turn_off', icon: _icon(P.power), label: t('ha_light.stop_action.turn_off'), desc: t('ha_light.stop_action.turn_off.desc') },
+ { value: 'restore', icon: _icon(P.rotateCcw), label: t('ha_light.stop_action.restore'), desc: t('ha_light.stop_action.restore.desc') },
+ ];
+}
+
+function _ensureStopActionIconSelect(): void {
+ const sel = document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement | null;
+ if (!sel) return;
+ const items = _stopActionItems();
+ if (_stopActionIconSelect) { _stopActionIconSelect.updateItems(items); return; }
+ _stopActionIconSelect = new IconSelect({ target: sel, items, columns: 3 });
+}
+
function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget {
if (!_minBrightnessThresholdWidget) {
_minBrightnessThresholdWidget = new BindableScalarWidget({
@@ -344,6 +364,9 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
_ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0);
+ (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value = editData.stop_action ?? 'none';
+ _ensureStopActionIconSelect();
+ if (_stopActionIconSelect) _stopActionIconSelect.setValue(editData.stop_action ?? 'none', false);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
// Fetch entities from the selected HA source before loading mappings
@@ -359,6 +382,9 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
_ensureTransitionWidget().setValue(0.5);
_ensureColorToleranceWidget().setValue(5);
_ensureMinBrightnessThresholdWidget().setValue(0);
+ (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value = 'none';
+ _ensureStopActionIconSelect();
+ if (_stopActionIconSelect) _stopActionIconSelect.setValue('none', false);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
// Fetch entities from the first HA source
@@ -418,6 +444,9 @@ export async function saveHALightEditor(): Promise {
const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5;
const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0;
+ const stopActionRaw = (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value;
+ const stopAction: 'none' | 'turn_off' | 'restore' =
+ stopActionRaw === 'turn_off' || stopActionRaw === 'restore' ? stopActionRaw : 'none';
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
if (!name) {
@@ -444,6 +473,7 @@ export async function saveHALightEditor(): Promise {
transition,
color_tolerance: colorTolerance,
min_brightness_threshold: minBrightnessThreshold,
+ stop_action: stopAction,
description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
diff --git a/server/src/ledgrab/static/js/features/icon-picker.ts b/server/src/ledgrab/static/js/features/icon-picker.ts
index 73c1f74..99cca7a 100644
--- a/server/src/ledgrab/static/js/features/icon-picker.ts
+++ b/server/src/ledgrab/static/js/features/icon-picker.ts
@@ -1,16 +1,22 @@
/**
* Icon picker modal — choose a custom icon for an entity card.
*
- * Currently wired for devices (PATCH /devices/:id { icon, icon_color }).
- * The plumbing is generic so other entity types can opt in later by
- * registering a new ``onApply`` handler.
+ * Generic over entity types (devices, LED targets, …). The picker is
+ * opened by document-level click delegation matching:
+ *
+ * data-icon-picker-trigger=":"
+ *
+ * Each registered entity type provides: cache lookup, PATCH endpoint,
+ * reload callback, and an optional ``inheritedFrom`` resolver — used by
+ * LED targets to fall back to the related device's icon when the target
+ * doesn't have its own.
*/
import { Modal } from '../core/modal.ts';
import { t } from '../core/i18n.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { showToast } from '../core/ui.ts';
-import { devicesCache } from '../core/state.ts';
+import { devicesCache, outputTargetsCache } from '../core/state.ts';
import {
DEVICE_ICONS,
CATEGORIES,
@@ -25,12 +31,114 @@ import {
const RECENT_KEY = 'ledgrab.icon-picker.recent';
const RECENT_MAX = 10;
+// ────────────────────────────────────────────────────────────────
+// Entity-type registry
+// ────────────────────────────────────────────────────────────────
+
+type EntityType = 'device' | 'target';
+
+interface EntityRecord {
+ id: string;
+ name: string;
+ icon: string;
+ icon_color: string;
+}
+
+interface InheritedIcon {
+ /** The icon id we'd render if this entity has no own icon. */
+ iconId: string;
+ /** Effective color for the inherited icon. */
+ color: string;
+ /** Display name of the source (e.g. parent device name). */
+ fromName: string;
+}
+
+interface EntityTypeAdapter {
+ /** Look up the entity by id. Returns null when missing from the cache. */
+ lookup(id: string): EntityRecord | null;
+ /** Build the PUT endpoint URL for icon updates. */
+ endpoint(id: string): string;
+ /** Invalidate the cache and reload the relevant view. */
+ reload(): Promise;
+ /** Optional fallback icon (e.g. LED target → parent device). */
+ inheritedFrom(id: string): InheritedIcon | null;
+ /** Display label like "Device" / "LED target". */
+ typeLabel(): string;
+}
+
+function _readDevice(id: string): EntityRecord | null {
+ const dev = (devicesCache.data ?? []).find((d: any) => d.id === id);
+ if (!dev) return null;
+ return {
+ id: dev.id,
+ name: dev.name ?? dev.id,
+ icon: (dev.icon as string | undefined) ?? '',
+ icon_color: (dev.icon_color as string | undefined) ?? '',
+ };
+}
+
+function _readTarget(id: string): EntityRecord | null {
+ const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id);
+ if (!tgt) return null;
+ return {
+ id: tgt.id,
+ name: tgt.name ?? tgt.id,
+ icon: (tgt.icon as string | undefined) ?? '',
+ icon_color: (tgt.icon_color as string | undefined) ?? '',
+ };
+}
+
+const _adapters: Record = {
+ device: {
+ lookup: _readDevice,
+ endpoint: (id) => `/devices/${id}`,
+ reload: async () => {
+ devicesCache.invalidate();
+ await window.loadDevices?.();
+ },
+ inheritedFrom: () => null,
+ typeLabel: () => t('device.icon.entity.device') || 'Device',
+ },
+ target: {
+ lookup: _readTarget,
+ endpoint: (id) => `/output-targets/${id}`,
+ reload: async () => {
+ outputTargetsCache.invalidate();
+ await window.loadTargetsTab?.();
+ },
+ inheritedFrom: (id) => {
+ const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id);
+ if (!tgt) return null;
+ // Only LED targets inherit from a device — HA-light targets don't.
+ const deviceId = (tgt as any).device_id;
+ if (!deviceId) return null;
+ const dev = (devicesCache.data ?? []).find((d: any) => d.id === deviceId);
+ const iconId = (dev?.icon as string | undefined) ?? '';
+ if (!iconId) return null;
+ return {
+ iconId,
+ color: (dev?.icon_color as string | undefined) || '',
+ fromName: dev?.name ?? deviceId,
+ };
+ },
+ typeLabel: () => t('device.icon.entity.target') || 'LED target',
+ },
+};
+
+// ────────────────────────────────────────────────────────────────
+// Picker state
+// ────────────────────────────────────────────────────────────────
+
interface PickerContext {
- deviceId: string;
+ entityType: EntityType;
+ entityId: string;
+ entityName: string;
initialIconId: string;
initialColor: string;
/** CSS color used for the live channel preview (e.g. '#4CAF50'). */
channelColor: string;
+ /** Optional inherited icon (LED target → device). */
+ inherited: InheritedIcon | null;
}
let _ctx: PickerContext | null = null;
@@ -71,20 +179,33 @@ function _pushRecent(iconId: string): void {
// Public entry points
// ────────────────────────────────────────────────────────────────
-/** Open the picker for the given device. Reads current icon from cache. */
-export function openDeviceIconPicker(deviceId: string): void {
- if (!deviceId) return;
- const device = (devicesCache.data ?? []).find((d: any) => d.id === deviceId) ?? null;
- const initialIconId = (device?.icon as string | undefined) ?? '';
- const initialColor = (device?.icon_color as string | undefined) ?? '';
+/** Open the picker for the given entity. Reads current icon from cache. */
+export function openIconPicker(entityType: EntityType, entityId: string): void {
+ if (!entityId) return;
+ const adapter = _adapters[entityType];
+ if (!adapter) return;
+
+ const rec = adapter.lookup(entityId);
+ const initialIconId = rec?.icon ?? '';
+ const initialColor = rec?.icon_color ?? '';
+ const inherited = adapter.inheritedFrom(entityId);
// Resolve channel color from the live card so the preview matches.
- const card = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
+ const cardAttr = entityType === 'device' ? 'data-device-id' : 'data-target-id';
+ const card = document.querySelector(`[${cardAttr}="${CSS.escape(entityId)}"]`) as HTMLElement | null;
const channelColor = card
? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel()
: _fallbackChannel();
- _ctx = { deviceId, initialIconId, initialColor, channelColor };
+ _ctx = {
+ entityType,
+ entityId,
+ entityName: rec?.name ?? entityId,
+ initialIconId,
+ initialColor,
+ channelColor,
+ inherited,
+ };
_selectedIconId = initialIconId;
_selectedColor = initialColor;
_activeCategory = 'all';
@@ -96,13 +217,17 @@ export function openDeviceIconPicker(deviceId: string): void {
_renderModal();
_modalInstance.open();
- // Focus search after open — done in the next frame so the modal is visible.
requestAnimationFrame(() => {
const search = document.getElementById('icon-picker-search') as HTMLInputElement | null;
search?.focus();
});
}
+/** Back-compat shim — early callers still import this name. */
+export function openDeviceIconPicker(deviceId: string): void {
+ openIconPicker('device', deviceId);
+}
+
/** Close the picker without applying changes. */
export function closeIconPicker(): void {
_modalInstance?.close();
@@ -118,11 +243,29 @@ function _fallbackChannel(): string {
return (root.getPropertyValue('--ch-signal') || '#4CAF50').trim();
}
+/** What the preview displays right now (resolves inheritance). */
+function _resolveDisplayIcon(): { iconId: string; color: string; isInherited: boolean } {
+ if (!_ctx) return { iconId: '', color: '', isInherited: false };
+ if (_selectedIconId) {
+ return { iconId: _selectedIconId, color: _selectedColor || _ctx.channelColor, isInherited: false };
+ }
+ if (_ctx.inherited) {
+ return {
+ iconId: _ctx.inherited.iconId,
+ color: _ctx.inherited.color || _ctx.channelColor,
+ isInherited: true,
+ };
+ }
+ return { iconId: '', color: _ctx.channelColor, isInherited: false };
+}
+
function _renderModal(): void {
if (!_ctx) return;
const previewEl = document.getElementById('icon-picker-preview') as HTMLElement | null;
const titleNameEl = document.getElementById('icon-picker-device-name') as HTMLElement | null;
+ const eyebrowEl = document.getElementById('icon-picker-eyebrow') as HTMLElement | null;
+ const subEl = document.getElementById('icon-picker-sub') as HTMLElement | null;
const swatchEl = document.getElementById('icon-picker-swatch') as HTMLElement | null;
const tabsEl = document.getElementById('icon-picker-tabs') as HTMLElement | null;
const recentEl = document.getElementById('icon-picker-recent') as HTMLElement | null;
@@ -132,20 +275,31 @@ function _renderModal(): void {
if (!previewEl || !tabsEl || !gridEl) return;
- // Resolve effective color for the preview (override > channel)
- const effectiveColor = _selectedColor || _ctx.channelColor;
+ const display = _resolveDisplayIcon();
+ const effectiveColor = display.color;
previewEl.style.setProperty('--ch', effectiveColor);
previewEl.style.color = effectiveColor;
- previewEl.innerHTML = _selectedIconId
- ? renderDeviceIconSvg(_selectedIconId, { size: 30 })
- : ``;
- if (!_selectedIconId) previewEl.classList.add('is-empty');
- else previewEl.classList.remove('is-empty');
+ previewEl.classList.toggle('is-inherited', display.isInherited && !!display.iconId);
+ previewEl.classList.toggle('is-empty', !display.iconId);
+ previewEl.innerHTML = display.iconId
+ ? renderDeviceIconSvg(display.iconId, { size: 30 })
+ : ``;
- // Device name in the header
- if (titleNameEl) {
- const device = (devicesCache.data ?? []).find((d: any) => d.id === _ctx!.deviceId);
- titleNameEl.textContent = device?.name ?? _ctx.deviceId;
+ // Header — entity type + name, plus inherited hint when applicable.
+ const adapter = _adapters[_ctx.entityType];
+ if (eyebrowEl) {
+ eyebrowEl.textContent = adapter.typeLabel();
+ }
+ if (titleNameEl) titleNameEl.textContent = _ctx.entityName;
+ if (subEl) {
+ if (display.isInherited) {
+ const tmpl = t('device.icon.inherited_from') || 'Inherited from %s';
+ subEl.textContent = tmpl.replace('%s', _ctx.inherited!.fromName);
+ subEl.classList.add('is-inherited');
+ } else {
+ subEl.textContent = '';
+ subEl.classList.remove('is-inherited');
+ }
}
// Swatch reflects current effective color
@@ -154,15 +308,12 @@ function _renderModal(): void {
swatchEl.style.borderColor = effectiveColor;
}
- // Search input value (only set if not focused — preserves caret)
if (searchEl && document.activeElement !== searchEl) {
searchEl.value = _query;
}
- // Tabs
tabsEl.innerHTML = _renderTabsHtml();
- // Recent strip
if (recentEl) {
const recent = _readRecent();
if (recent.length === 0) {
@@ -176,12 +327,20 @@ function _renderModal(): void {
}
}
- // Grid (filtered + grouped or flat depending on query/category)
gridEl.innerHTML = _renderGridHtml();
- // Remove button — disabled when there's no current icon
+ // Remove button — meaning depends on whether the entity has an own icon
+ // and whether an inherited fallback exists.
if (removeBtn) {
- removeBtn.disabled = !_selectedIconId && !_ctx.initialIconId;
+ const hasOwn = !!_selectedIconId || !!_ctx.initialIconId;
+ removeBtn.disabled = !hasOwn;
+ // When clearing would reveal an inherited icon, label the button
+ // "Use inherited" instead of "Remove icon" so the user knows what
+ // happens next.
+ const willInherit = _ctx.inherited != null;
+ const labelKey = willInherit ? 'device.icon.use_inherited' : 'device.icon.remove';
+ const fallback = willInherit ? 'Use inherited' : 'Remove icon';
+ removeBtn.textContent = t(labelKey) !== labelKey ? t(labelKey) : fallback;
}
}
@@ -209,7 +368,6 @@ function _renderGridHtml(): string {
return `${escapeHtml(t('device.icon.empty') || 'No icons match.')}
`;
}
- // When searching or on a single category, render flat. Otherwise group.
if (_query || _activeCategory !== 'all') {
return `${inCat.map(_iconTileHtml).join('')}
`;
}
@@ -234,34 +392,38 @@ function _iconTileHtml(def: DeviceIconDef | null): string {
}
// ────────────────────────────────────────────────────────────────
-// Apply / events
+// Apply / remove
// ────────────────────────────────────────────────────────────────
-async function _applyToDevice(): Promise {
+async function _applyChange(nextIconId: string, nextColor: string): Promise {
if (!_ctx) return;
- const { deviceId, initialIconId, initialColor } = _ctx;
- if (_selectedIconId === initialIconId && _selectedColor === initialColor) {
+ const { entityType, entityId, initialIconId, initialColor } = _ctx;
+ if (nextIconId === initialIconId && nextColor === initialColor) {
closeIconPicker();
return;
}
+ const adapter = _adapters[entityType];
try {
- const resp = await fetchWithAuth(`/devices/${deviceId}`, {
+ const body: Record = { icon: nextIconId, icon_color: nextColor };
+ // The output-targets endpoint requires the discriminator field.
+ if (entityType === 'target') {
+ const tgt = (outputTargetsCache.data ?? []).find((x: any) => x.id === entityId);
+ const targetType = (tgt as any)?.target_type ?? 'led';
+ body.target_type = targetType;
+ }
+ const resp = await fetchWithAuth(adapter.endpoint(entityId), {
method: 'PUT',
- body: JSON.stringify({
- icon: _selectedIconId,
- icon_color: _selectedColor,
- }),
+ body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
- showToast((err && err.detail) || t('device.icon.error.save_failed'), 'error');
+ showToast((err && (err as any).detail) || t('device.icon.error.save_failed'), 'error');
return;
}
- if (_selectedIconId) _pushRecent(_selectedIconId);
+ if (nextIconId) _pushRecent(nextIconId);
showToast(t('device.icon.saved') || 'Icon saved', 'success');
- devicesCache.invalidate();
- await window.loadDevices?.();
+ await adapter.reload();
closeIconPicker();
} catch (error: any) {
if (error?.isAuth) return;
@@ -269,11 +431,14 @@ async function _applyToDevice(): Promise {
}
}
-async function _removeIcon(): Promise {
- if (!_ctx) return;
- _selectedIconId = '';
- _selectedColor = '';
- await _applyToDevice();
+async function _applyCurrentSelection(): Promise {
+ await _applyChange(_selectedIconId, _selectedColor);
+}
+
+async function _removeOwnIcon(): Promise {
+ // Clear the entity's own icon — for targets with an inherited fallback,
+ // the card will then render the device's icon.
+ await _applyChange('', '');
}
function _selectIcon(iconId: string): void {
@@ -288,30 +453,22 @@ function _setCategory(cat: IconCategory | 'all'): void {
function _setQuery(q: string): void {
_query = q;
- // Switch to "all" tab when a query is typed so search reaches all icons.
if (q && _activeCategory !== 'all') _activeCategory = 'all';
_renderModal();
}
function _toggleColorOverride(): void {
if (!_ctx) return;
- // Cycle: channel default → muted accent → channel default. We keep the
- // override surface minimal — power users can drop in a hex via dev-tools
- // until a full color picker is added.
if (_selectedColor) {
_selectedColor = '';
} else {
- // Use the channel color as a starting override so the user sees
- // immediate feedback that the toggle does something. This is also
- // the simplest "yes I want a custom color" affordance — they can
- // refine later.
_selectedColor = _ctx.channelColor;
}
_renderModal();
}
// ────────────────────────────────────────────────────────────────
-// Event delegation — bound once on first import
+// Modal-internal event wiring
// ────────────────────────────────────────────────────────────────
let _wired = false;
@@ -343,7 +500,7 @@ function _wireEvents(): void {
return;
}
if (target.closest('#icon-picker-apply')) {
- _applyToDevice();
+ _applyCurrentSelection();
return;
}
if (target.closest('#icon-picker-cancel') || target.closest('.icon-picker-close')) {
@@ -351,7 +508,7 @@ function _wireEvents(): void {
return;
}
if (target.closest('#icon-picker-remove')) {
- _removeIcon();
+ _removeOwnIcon();
return;
}
});
@@ -364,12 +521,11 @@ function _wireEvents(): void {
root.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'BUTTON') {
e.preventDefault();
- _applyToDevice();
+ _applyCurrentSelection();
}
});
}
-// Wire as soon as the DOM is ready (or immediately if it already is).
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _wireEvents, { once: true });
} else {
@@ -377,19 +533,22 @@ if (document.readyState === 'loading') {
}
// ────────────────────────────────────────────────────────────────
-// Document-level click delegation — opens the picker for any element
-// matching ``[data-icon-picker-trigger=""]`` (the icon plate
-// on each card and the "Change icon…" item in the kebab menu). Avoids
-// polluting ``window`` with an inline onclick target.
+// Document-level click delegation. Triggers match
+// ``[data-icon-picker-trigger=":"]``. Legacy
+// triggers without a colon default to ``device:`` so older cards
+// keep working during the rollout.
// ────────────────────────────────────────────────────────────────
function _onDocumentClick(e: MouseEvent): void {
const el = (e.target as HTMLElement | null)?.closest('[data-icon-picker-trigger]') as HTMLElement | null;
if (!el) return;
- const deviceId = el.getAttribute('data-icon-picker-trigger') || '';
- if (!deviceId) return;
+ const raw = el.getAttribute('data-icon-picker-trigger') || '';
+ if (!raw) return;
+ const [typeOrId, id] = raw.includes(':') ? raw.split(':', 2) : ['device', raw];
+ if (!id) return;
+ if (typeOrId !== 'device' && typeOrId !== 'target') return;
e.stopPropagation();
- openDeviceIconPicker(deviceId);
+ openIconPicker(typeOrId as EntityType, id);
}
document.addEventListener('click', _onDocumentClick);
diff --git a/server/src/ledgrab/static/js/features/targets.ts b/server/src/ledgrab/static/js/features/targets.ts
index 30ce20d..693f57d 100644
--- a/server/src/ledgrab/static/js/features/targets.ts
+++ b/server/src/ledgrab/static/js/features/targets.ts
@@ -30,8 +30,9 @@ import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
-import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, LedState } from '../core/mod-card.ts';
+import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, ModMenuItemOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
+import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts';
@@ -1015,20 +1016,34 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
// instrument routed through the device. ──
const badgeText = 'LED · TGT';
- // ── Meta line: device link · protocol · target FPS · pixel count.
- // Protocol carries device-type-specific richness (OpenRGB SDK,
- // Adalight serial, etc.) — _protocolBadge() returns icon + label. ──
- const deviceLink = target.device_id
- ? `${escapeHtml(deviceName)}`
- : escapeHtml(deviceName);
+ // ── Meta line: protocol · target FPS · pixel count.
+ // The device link used to live here too; it now appears as a
+ // content chip below (mirrors how the color-strip-source link is
+ // rendered, and gives space for the device's custom icon). ──
const targetFps = bindableValue(target.fps, 30);
- const metaParts: string[] = [deviceLink, _protocolBadge(device, target), `${targetFps} fps`];
+ const metaParts: string[] = [_protocolBadge(device, target), `${targetFps} fps`];
const ledCount = device?.state?.device_led_count || device?.led_count;
if (ledCount) metaParts.push(`${ledCount} px`);
const metaHtml = metaParts.join(' · ');
- // ── Chips: CSS source link, brightness override, threshold ──
+ // ── Chips: device link, CSS source link, brightness override, threshold ──
const chips: ModChipOpts[] = [];
+ // Device chip — leads the row so the user reads "this target →
+ // {device} → {color-strip source}". When the device has a custom
+ // icon it wins over the generic device-type fallback.
+ if (target.device_id) {
+ const deviceIconHtml = device?.icon
+ ? renderDeviceIconSvg(device.icon, { size: 11, strokeWidth: 1.7 })
+ : getDeviceTypeIcon(device?.device_type || 'wled');
+ chips.push({
+ icon: deviceIconHtml,
+ text: deviceName,
+ title: t('targets.device'),
+ onclick: device
+ ? `event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')`
+ : undefined,
+ });
+ }
if (cssSource) {
chips.push({
icon: ICON_FILM,
@@ -1163,6 +1178,29 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
title: t('common.edit'),
});
+ // ── Custom icon plate (target-own > inherited from device) ──
+ const targetIconId = (target as LedOutputTarget).icon || '';
+ const targetIconColor = (target as LedOutputTarget).icon_color || '';
+ const inheritedIconId = !targetIconId ? (device?.icon as string | undefined) || '' : '';
+ const effectiveIconId = targetIconId || inheritedIconId;
+ const effectiveIconColor = targetIconColor || (inheritedIconId ? (device?.icon_color || '') : '');
+ const iconHtml = effectiveIconId ? renderDeviceIconSvg(effectiveIconId, { size: 24 }) : '';
+ const iconTitle = targetIconId
+ ? (t('device.icon.change') || 'Change icon…')
+ : inheritedIconId
+ ? (t('device.icon.override_inherited') || 'Override inherited icon…')
+ : (t('device.icon.choose') || 'Choose icon…');
+
+ const targetMenuExtraItems: ModMenuItemOpts[] = [
+ {
+ label: targetIconId
+ ? (t('device.icon.change') || 'Change icon…')
+ : (t('device.icon.choose') || 'Choose icon…'),
+ icon: ICON_EDIT,
+ dataAttrs: { 'data-icon-picker-trigger': `target:${target.id}` },
+ },
+ ];
+
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
@@ -1170,7 +1208,12 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
metaHtml,
healthDot,
leds,
+ iconHtml,
+ iconColor: effectiveIconColor,
+ iconAttrs: { 'data-icon-picker-trigger': `target:${target.id}` },
+ iconTitle,
menu: {
+ extraItems: targetMenuExtraItems,
duplicateOnclick: `cloneTarget('${target.id}')`,
hideOnclick: `toggleCardHidden('led-targets','${target.id}')`,
deleteOnclick: `deleteTarget('${target.id}')`,
diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts
index 8f0cc11..03aa307 100644
--- a/server/src/ledgrab/static/js/types.ts
+++ b/server/src/ledgrab/static/js/types.ts
@@ -105,6 +105,13 @@ interface OutputTargetBase {
target_type: TargetType;
description?: string;
tags: string[];
+ /** Optional id from the curated icon library. Empty/missing →
+ * for LED targets, the card inherits the device's icon; for
+ * HA-light targets, no plate is rendered. */
+ icon?: string;
+ /** Optional CSS color override for the icon. Empty/missing →
+ * inherits the device color (LED targets) or --ch (others). */
+ icon_color?: string;
created_at: string;
updated_at: string;
}
diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json
index e0c9107..16920f4 100644
--- a/server/src/ledgrab/static/locales/en.json
+++ b/server/src/ledgrab/static/locales/en.json
@@ -582,6 +582,11 @@
"device.icon.cat.media": "Media",
"device.icon.cat.signal": "Signal",
"device.icon.cat.ambience": "Ambience",
+ "device.icon.entity.device": "Device",
+ "device.icon.entity.target": "LED target",
+ "device.icon.inherited_from": "Inherited from %s",
+ "device.icon.override_inherited": "Override inherited icon…",
+ "device.icon.use_inherited": "Use inherited",
"validation.required": "This field is required",
"bulk.processing": "Processing…",
"api.error.timeout": "Request timed out — please try again",
@@ -2198,6 +2203,14 @@
"ha_light.updated": "HA light target updated",
"ha_light.mapping.select_entity": "Select a light entity...",
"ha_light.mapping.search_entity": "Search light entities...",
+ "ha_light.stop_action": "On Stop:",
+ "ha_light.stop_action.hint": "What to do with the mapped lights when this target stops streaming.",
+ "ha_light.stop_action.none": "None",
+ "ha_light.stop_action.none.desc": "Leave lights as-is",
+ "ha_light.stop_action.turn_off": "Turn Off",
+ "ha_light.stop_action.turn_off.desc": "Switch all mapped lights off",
+ "ha_light.stop_action.restore": "Restore",
+ "ha_light.stop_action.restore.desc": "Revert to state captured at start",
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
"automations.rule.home_assistant": "Home Assistant",
"automations.rule.home_assistant.desc": "HA entity state",
diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json
index eb76489..5e4128a 100644
--- a/server/src/ledgrab/static/locales/ru.json
+++ b/server/src/ledgrab/static/locales/ru.json
@@ -6,6 +6,14 @@
"ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.",
"ha_light.min_brightness_threshold": "Мин. порог яркости:",
"ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).",
+ "ha_light.stop_action": "При остановке:",
+ "ha_light.stop_action.hint": "Что делать с привязанными лампами, когда цель прекращает стриминг.",
+ "ha_light.stop_action.none": "Ничего",
+ "ha_light.stop_action.none.desc": "Оставить лампы как есть",
+ "ha_light.stop_action.turn_off": "Выключить",
+ "ha_light.stop_action.turn_off.desc": "Выключить все привязанные лампы",
+ "ha_light.stop_action.restore": "Восстановить",
+ "ha_light.stop_action.restore.desc": "Вернуть состояние на момент запуска",
"app.version": "Версия:",
"app.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен",
@@ -586,6 +594,11 @@
"device.icon.cat.media": "Медиа",
"device.icon.cat.signal": "Сигнал",
"device.icon.cat.ambience": "Атмосфера",
+ "device.icon.entity.device": "Устройство",
+ "device.icon.entity.target": "LED-цель",
+ "device.icon.inherited_from": "Унаследовано от %s",
+ "device.icon.override_inherited": "Заменить унаследованную иконку…",
+ "device.icon.use_inherited": "Использовать унаследованную",
"validation.required": "Обязательное поле",
"bulk.processing": "Обработка…",
"api.error.timeout": "Превышено время ожидания — попробуйте снова",
diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json
index ad808cb..479a644 100644
--- a/server/src/ledgrab/static/locales/zh.json
+++ b/server/src/ledgrab/static/locales/zh.json
@@ -6,6 +6,14 @@
"ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。",
"ha_light.min_brightness_threshold": "最低亮度阈值:",
"ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。",
+ "ha_light.stop_action": "停止时:",
+ "ha_light.stop_action.hint": "此目标停止流式传输时对映射的灯执行的操作。",
+ "ha_light.stop_action.none": "无",
+ "ha_light.stop_action.none.desc": "保持灯的当前状态",
+ "ha_light.stop_action.turn_off": "关闭",
+ "ha_light.stop_action.turn_off.desc": "关闭所有映射的灯",
+ "ha_light.stop_action.restore": "恢复",
+ "ha_light.stop_action.restore.desc": "恢复到启动时捕获的状态",
"app.version": "版本:",
"app.api_docs": "API 文档",
"app.connection_lost": "服务器不可达",
@@ -586,6 +594,11 @@
"device.icon.cat.media": "媒体",
"device.icon.cat.signal": "信号",
"device.icon.cat.ambience": "氛围",
+ "device.icon.entity.device": "设备",
+ "device.icon.entity.target": "LED 目标",
+ "device.icon.inherited_from": "继承自 %s",
+ "device.icon.override_inherited": "覆盖继承的图标…",
+ "device.icon.use_inherited": "使用继承的",
"validation.required": "此字段为必填项",
"bulk.processing": "处理中…",
"api.error.timeout": "请求超时 — 请重试",
diff --git a/server/src/ledgrab/storage/ha_light_output_target.py b/server/src/ledgrab/storage/ha_light_output_target.py
index 8f25c8f..8ac162d 100644
--- a/server/src/ledgrab/storage/ha_light_output_target.py
+++ b/server/src/ledgrab/storage/ha_light_output_target.py
@@ -36,6 +36,9 @@ class HALightMapping:
)
+VALID_STOP_ACTIONS = ("none", "turn_off", "restore")
+
+
@dataclass
class HALightOutputTarget(OutputTarget):
"""Output target that casts LED colors to Home Assistant lights via service calls."""
@@ -48,6 +51,7 @@ class HALightOutputTarget(OutputTarget):
transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
+ stop_action: str = "none" # one of VALID_STOP_ACTIONS
def register_with_manager(self, manager) -> None:
"""Register this HA light target with the processor manager."""
@@ -62,6 +66,7 @@ class HALightOutputTarget(OutputTarget):
transition=self.transition,
min_brightness_threshold=self.min_brightness_threshold,
color_tolerance=self.color_tolerance,
+ stop_action=self.stop_action,
)
def sync_with_manager(
@@ -85,6 +90,7 @@ class HALightOutputTarget(OutputTarget):
"min_brightness_threshold": self.min_brightness_threshold,
"color_tolerance": self.color_tolerance,
"light_mappings": self.light_mappings,
+ "stop_action": self.stop_action,
},
)
if css_changed:
@@ -104,12 +110,21 @@ class HALightOutputTarget(OutputTarget):
transition=None,
min_brightness_threshold=None,
color_tolerance=None,
+ stop_action=None,
description=None,
tags: Optional[List[str]] = None,
+ icon: Optional[str] = None,
+ icon_color: Optional[str] = None,
**_kwargs,
) -> None:
"""Apply mutable field updates."""
- super().update_fields(name=name, description=description, tags=tags)
+ super().update_fields(
+ name=name,
+ description=description,
+ tags=tags,
+ icon=icon,
+ icon_color=icon_color,
+ )
if ha_source_id is not None:
self.ha_source_id = resolve_ref(ha_source_id, self.ha_source_id)
if color_strip_source_id is not None:
@@ -135,6 +150,8 @@ class HALightOutputTarget(OutputTarget):
)
if color_tolerance is not None:
self.color_tolerance = self.color_tolerance.apply_update(color_tolerance)
+ if stop_action is not None and stop_action in VALID_STOP_ACTIONS:
+ self.stop_action = stop_action
def to_dict(self) -> dict:
d = super().to_dict()
@@ -146,6 +163,7 @@ class HALightOutputTarget(OutputTarget):
d["transition"] = self.transition.to_dict()
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
d["color_tolerance"] = self.color_tolerance.to_dict()
+ d["stop_action"] = self.stop_action
return d
@classmethod
@@ -171,8 +189,13 @@ class HALightOutputTarget(OutputTarget):
data.get("min_brightness_threshold"), default=0.0
),
color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.0),
+ stop_action=(
+ data["stop_action"] if data.get("stop_action") in VALID_STOP_ACTIONS else "none"
+ ),
description=data.get("description"),
tags=data.get("tags", []),
+ icon=data.get("icon", ""),
+ icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
diff --git a/server/src/ledgrab/storage/output_target.py b/server/src/ledgrab/storage/output_target.py
index ea7d130..100de8c 100644
--- a/server/src/ledgrab/storage/output_target.py
+++ b/server/src/ledgrab/storage/output_target.py
@@ -16,6 +16,11 @@ class OutputTarget:
updated_at: datetime
description: Optional[str] = None
tags: List[str] = field(default_factory=list)
+ # Custom card icon (frontend display only). When empty, the LED target
+ # card inherits the icon from its referenced device; HA-light targets
+ # have no inheritance source and just show the empty plate placeholder.
+ icon: str = ""
+ icon_color: str = ""
def register_with_manager(self, manager) -> None:
"""Register this target with the processor manager. Subclasses override."""
@@ -34,6 +39,8 @@ class OutputTarget:
device_id=None,
description=None,
tags: Optional[List[str]] = None,
+ icon: Optional[str] = None,
+ icon_color: Optional[str] = None,
**_kwargs,
) -> None:
"""Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones."""
@@ -43,10 +50,14 @@ class OutputTarget:
self.description = description
if tags is not None:
self.tags = tags
+ if icon is not None:
+ self.icon = icon
+ if icon_color is not None:
+ self.icon_color = icon_color
def to_dict(self) -> dict:
"""Convert to dictionary."""
- return {
+ d = {
"id": self.id,
"name": self.name,
"target_type": self.target_type,
@@ -55,6 +66,11 @@ class OutputTarget:
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+ if self.icon:
+ d["icon"] = self.icon
+ if self.icon_color:
+ d["icon_color"] = self.icon_color
+ return d
@classmethod
def from_dict(cls, data: dict) -> "OutputTarget":
diff --git a/server/src/ledgrab/storage/output_target_store.py b/server/src/ledgrab/storage/output_target_store.py
index f8ffd31..9d08c36 100644
--- a/server/src/ledgrab/storage/output_target_store.py
+++ b/server/src/ledgrab/storage/output_target_store.py
@@ -54,6 +54,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
update_rate: float = 2.0,
transition=None,
color_tolerance: int = 5,
+ stop_action: str = "none",
# legacy compat
brightness_value_source_id: str = "",
) -> OutputTarget:
@@ -126,6 +127,9 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold, default=0.0
),
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0),
+ stop_action=(
+ stop_action if stop_action in ("none", "turn_off", "restore") else "none"
+ ),
description=description,
created_at=now,
updated_at=now,
@@ -155,11 +159,14 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
protocol=None,
description=None,
tags=None,
+ icon=None,
+ icon_color=None,
ha_source_id=None,
ha_light_mappings=None,
update_rate=None,
transition=None,
color_tolerance=None,
+ stop_action=None,
# legacy compat
brightness_value_source_id=None,
) -> OutputTarget:
@@ -193,11 +200,14 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
protocol=protocol,
description=description,
tags=tags,
+ icon=icon,
+ icon_color=icon_color,
ha_source_id=ha_source_id,
light_mappings=ha_light_mappings,
update_rate=update_rate,
transition=transition,
color_tolerance=color_tolerance,
+ stop_action=stop_action,
)
target.updated_at = datetime.now(timezone.utc)
diff --git a/server/src/ledgrab/storage/wled_output_target.py b/server/src/ledgrab/storage/wled_output_target.py
index 2266e8f..727f078 100644
--- a/server/src/ledgrab/storage/wled_output_target.py
+++ b/server/src/ledgrab/storage/wled_output_target.py
@@ -83,10 +83,18 @@ class WledOutputTarget(OutputTarget):
protocol=None,
description=None,
tags: Optional[List[str]] = None,
+ icon: Optional[str] = None,
+ icon_color: Optional[str] = None,
**_kwargs,
) -> None:
"""Apply mutable field updates for WLED targets."""
- super().update_fields(name=name, description=description, tags=tags)
+ super().update_fields(
+ name=name,
+ description=description,
+ tags=tags,
+ icon=icon,
+ icon_color=icon_color,
+ )
if device_id is not None:
self.device_id = resolve_ref(device_id, self.device_id)
if color_strip_source_id is not None:
@@ -159,6 +167,8 @@ class WledOutputTarget(OutputTarget):
protocol=data.get("protocol", "ddp"),
description=data.get("description"),
tags=data.get("tags", []),
+ icon=data.get("icon", ""),
+ icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
diff --git a/server/src/ledgrab/templates/modals/ha-light-editor.html b/server/src/ledgrab/templates/modals/ha-light-editor.html
index edcac67..9463b6d 100644
--- a/server/src/ledgrab/templates/modals/ha-light-editor.html
+++ b/server/src/ledgrab/templates/modals/ha-light-editor.html
@@ -114,6 +114,15 @@
Output brightness multiplier (0–1). Can be bound to a value source for dynamic control.
+
+
diff --git a/server/src/ledgrab/templates/modals/icon-picker.html b/server/src/ledgrab/templates/modals/icon-picker.html
index a000bc8..15b7ef0 100644
--- a/server/src/ledgrab/templates/modals/icon-picker.html
+++ b/server/src/ledgrab/templates/modals/icon-picker.html
@@ -10,12 +10,11 @@