feat(targets): customisable card icon + HA-light stop action

Extends the icon-plate work from the device cards to LED and HA-light
output targets, and adds finalization behaviour for HA-light targets.

Targets:
- Add `icon` and `icon_color` fields to OutputTarget (LED + HA-light).
- LED target cards inherit the icon from their referenced device when
  no override is set; the icon picker shows an "inherited" indicator.
- Promote the device link from the meta line to a chip with the
  device's custom icon, leaving the head row free for the icon plate.

HA-light:
- New `stop_action` field with three modes: `none` / `turn_off` /
  `restore`. Processor snapshots mapped-entity states at start and
  applies the chosen action on stop (rgb / hs / color_temp / brightness
  restored where present).
- Editor modal exposes the choice via an IconSelect of three modes.

Adjacent fixes:
- Fader slider hit-zone now overlays the visible track exactly,
  regardless of label/value column widths.
- Dashboard customise drag-drop indicator splits into before/after
  rather than highlighting the whole row.
- Picture-source EntitySelect resyncs its visible value on load.
This commit is contained in:
2026-05-04 00:43:55 +03:00
parent 49ddabbc36
commit ced72fc864
24 changed files with 831 additions and 134 deletions
@@ -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,
@@ -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[
@@ -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},
)
@@ -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
+11 -5
View File
@@ -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;
@@ -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 {
+7 -2
View File
@@ -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;
+13
View File
@@ -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 {
@@ -369,8 +369,10 @@ export function renderModFader(f: ModFaderOpts): string {
const disabledAttr = f.disabled ? ' disabled' : '';
return `<div class="mod-fader">
<span class="mod-fader__k">${escapeHtml(f.label)}</span>
<div class="mod-fader__track"><div class="mod-fader__fill" style="width:${pct}%"></div></div>
<input type="range" class="mod-fader__slider" min="0" max="${max}" value="${f.value}"${sliderId} ${dataAttrs}${oninputAttr}${onchangeAttr}${disabledAttr}>
<div class="mod-fader__lane">
<div class="mod-fader__track"><div class="mod-fader__fill" style="width:${pct}%"></div></div>
<input type="range" class="mod-fader__slider" min="0" max="${max}" value="${f.value}"${sliderId} ${dataAttrs}${oninputAttr}${onchangeAttr}${disabledAttr}>
</div>
<span class="mod-fader__v">${f.value}</span>
</div>`;
}
@@ -1272,6 +1272,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => 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);
@@ -89,6 +89,15 @@ const PERF_CELL_LABEL_KEYS: Record<string, string> = {
};
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<HTMLElement>(_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<HTMLElement>(listSelector);
if (!list) return;
let dragKey: string | null = null;
list.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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);
});
}
@@ -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<void> {
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<void> {
transition,
color_tolerance: colorTolerance,
min_brightness_threshold: minBrightnessThreshold,
stop_action: stopAction,
description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
@@ -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="<entityType>:<entityId>"
*
* 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<void>;
/** 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<EntityType, EntityTypeAdapter> = {
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 })
: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`;
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 })
: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`;
// 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 `<div class="icon-picker-empty">${escapeHtml(t('device.icon.empty') || 'No icons match.')}</div>`;
}
// When searching or on a single category, render flat. Otherwise group.
if (_query || _activeCategory !== 'all') {
return `<div class="icon-picker-grid">${inCat.map(_iconTileHtml).join('')}</div>`;
}
@@ -234,34 +392,38 @@ function _iconTileHtml(def: DeviceIconDef | null): string {
}
// ────────────────────────────────────────────────────────────────
// Apply / events
// Apply / remove
// ────────────────────────────────────────────────────────────────
async function _applyToDevice(): Promise<void> {
async function _applyChange(nextIconId: string, nextColor: string): Promise<void> {
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<string, unknown> = { 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<void> {
}
}
async function _removeIcon(): Promise<void> {
if (!_ctx) return;
_selectedIconId = '';
_selectedColor = '';
await _applyToDevice();
async function _applyCurrentSelection(): Promise<void> {
await _applyChange(_selectedIconId, _selectedColor);
}
async function _removeOwnIcon(): Promise<void> {
// 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="<deviceId>"]`` (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="<entityType>:<entityId>"]``. Legacy
// triggers without a colon default to ``device:<id>`` 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);
@@ -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
? `<a class="mod-meta__link" role="button" tabindex="0" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')" title="${escapeHtml(t('targets.device'))}">${escapeHtml(deviceName)}</a>`
: 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}')`,
+7
View File
@@ -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;
}
+13
View File
@@ -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",
+13
View File
@@ -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": "Превышено время ожидания — попробуйте снова",
+13
View File
@@ -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": "请求超时 — 请重试",
@@ -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())
),
+17 -1
View File
@@ -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":
@@ -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)
@@ -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())
),
@@ -114,6 +114,15 @@
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (01). Can be bound to a value source for dynamic control.</small>
<div id="ha-light-editor-brightness-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-stop-action" data-i18n="ha_light.stop_action">On Stop:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.stop_action.hint">What to do with the mapped lights when this target stops streaming.</small>
<select id="ha-light-editor-stop-action"></select>
</div>
</div>
</section>
@@ -10,12 +10,11 @@
<div id="icon-picker-preview" class="mod-icon icon-picker-preview" aria-hidden="true"></div>
</div>
<div class="icon-picker-meta">
<div class="icon-picker-eyebrow" data-i18n="device.icon.eyebrow">Card icon</div>
<h2 id="icon-picker-title" class="icon-picker-title" data-i18n="device.icon.title">Choose an icon</h2>
<div class="icon-picker-sub">
<span data-i18n="device.icon.for">for</span>
<div id="icon-picker-eyebrow" class="icon-picker-eyebrow">Card icon</div>
<h2 id="icon-picker-title" class="icon-picker-title">
<strong id="icon-picker-device-name"></strong>
</div>
</h2>
<div id="icon-picker-sub" class="icon-picker-sub"></div>
</div>
<button class="modal-close-btn icon-picker-close" type="button" data-i18n-aria-label="aria.close" aria-label="Close">&#x2715;</button>
</div>