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, protocol=target.protocol,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
icon=getattr(target, "icon", "") or "",
icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -82,8 +84,11 @@ def _ha_light_target_to_response(
transition=target.transition.to_dict(), transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(), color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(), min_brightness_threshold=target.min_brightness_threshold.to_dict(),
stop_action=target.stop_action,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
icon=getattr(target, "icon", "") or "",
icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -165,6 +170,7 @@ async def create_target(
update_rate=getattr(data, "update_rate", 2.0), update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5), transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5), color_tolerance=getattr(data, "color_tolerance", 5),
stop_action=getattr(data, "stop_action", "none"),
) )
# Register in processor manager # Register in processor manager
@@ -283,11 +289,14 @@ async def update_target(
protocol=getattr(data, "protocol", None), protocol=getattr(data, "protocol", None),
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
ha_source_id=getattr(data, "ha_source_id", None), ha_source_id=getattr(data, "ha_source_id", None),
ha_light_mappings=ha_mappings, ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", None), update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None), transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", 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) # Sync processor manager (run in thread — css release/acquire can block)
@@ -301,6 +310,7 @@ async def update_target(
transition = getattr(data, "transition", None) transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None) color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None) brightness = getattr(data, "brightness", None)
stop_action = getattr(data, "stop_action", None)
try: try:
await asyncio.to_thread( await asyncio.to_thread(
@@ -317,6 +327,7 @@ async def update_target(
or color_tolerance is not None or color_tolerance is not None
or ha_light_mappings_raw is not None or ha_light_mappings_raw is not None
or brightness is not None or brightness is not None
or stop_action is not None
), ),
css_changed=color_strip_source_id is not None, css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None, brightness_changed=brightness is not None,
@@ -55,6 +55,8 @@ class _OutputTargetResponseBase(BaseModel):
name: str = Field(description="Target name") name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -98,6 +100,11 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)" 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[ OutputTargetResponse = Annotated[
@@ -119,6 +126,12 @@ class _OutputTargetCreateBase(BaseModel):
name: str = Field(description="Target name", min_length=1, max_length=100) name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") 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): class LedOutputTargetCreate(_OutputTargetCreateBase):
@@ -180,6 +193,10 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default=0, default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off", 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[ OutputTargetCreate = Annotated[
@@ -201,6 +218,16 @@ class _OutputTargetUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100) 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None 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): class LedOutputTargetUpdate(_OutputTargetUpdateBase):
@@ -246,6 +273,9 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)" 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[ OutputTargetUpdate = Annotated[
@@ -35,6 +35,7 @@ class HALightTargetProcessor(TargetProcessor):
transition=None, transition=None,
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
color_tolerance: int = 5, color_tolerance: int = 5,
stop_action: str = "none",
ctx: Optional[TargetContext] = None, ctx: Optional[TargetContext] = None,
): ):
from ledgrab.storage.bindable import BindableFloat, bfloat 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._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._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._color_tolerance = int(bfloat(color_tolerance, 5.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 # Runtime state
self._css_stream = None self._css_stream = None
@@ -64,6 +68,8 @@ class HALightTargetProcessor(TargetProcessor):
self._previous_colors: Dict[str, Tuple[int, int, int]] = {} self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
self._previous_on: Dict[str, bool] = {} # track on/off state per entity self._previous_on: Dict[str, bool] = {} # track on/off state per entity
self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {} 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._ws_clients: List[Any] = []
self._start_time: Optional[float] = None 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}") logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
self._value_stream = None 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._is_running = True
self._start_time = time.monotonic() self._start_time = time.monotonic()
self._task = asyncio.create_task(self._processing_loop()) self._task = asyncio.create_task(self._processing_loop())
@@ -119,6 +129,14 @@ class HALightTargetProcessor(TargetProcessor):
pass pass
self._task = None 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 # Release CSS stream
if self._css_stream and self._ctx.color_strip_stream_manager: if self._css_stream and self._ctx.color_strip_stream_manager:
try: try:
@@ -148,6 +166,7 @@ class HALightTargetProcessor(TargetProcessor):
self._previous_colors.clear() self._previous_colors.clear()
self._previous_on.clear() self._previous_on.clear()
self._latest_entity_colors.clear() self._latest_entity_colors.clear()
self._captured_states.clear()
self._ws_clients.clear() self._ws_clients.clear()
logger.info(f"HA light target stopped: {self._target_id}") 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)) self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
if "light_mappings" in settings: if "light_mappings" in settings:
self._light_mappings = settings["light_mappings"] 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: def update_css_source(self, color_strip_source_id: str) -> None:
"""Hot-swap the CSS stream.""" """Hot-swap the CSS stream."""
@@ -378,3 +401,103 @@ class HALightTargetProcessor(TargetProcessor):
dead.append(ws) dead.append(ws)
for ws in dead: for ws in dead:
self._ws_clients.remove(ws) 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, transition=None,
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
color_tolerance: int = 5, color_tolerance: int = 5,
stop_action: str = "none",
) -> None: ) -> None:
"""Register a Home Assistant light target processor.""" """Register a Home Assistant light target processor."""
if target_id in self._processors: if target_id in self._processors:
@@ -454,6 +455,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
transition=transition, transition=transition,
min_brightness_threshold=min_brightness_threshold, min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance, color_tolerance=color_tolerance,
stop_action=stop_action,
ctx=self._build_context(), ctx=self._build_context(),
) )
self._processors[target_id] = proc 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)); color: var(--lux-ink-mute, var(--text-secondary));
min-width: 42px; min-width: 42px;
} }
.mod-fader__lane {
flex: 1;
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
.mod-fader__track { .mod-fader__track {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -2254,13 +2261,12 @@ ul.section-tip li {
var(--ch)); var(--ch));
box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 60%, transparent); 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 /* The slider input overlays the visible track exactly — same flex slot,
drags the visual track without seeing the native control. */ so its hit-zone aligns to the fill regardless of label/value width. */
.mod-fader { position: relative; }
.mod-fader__slider { .mod-fader__slider {
position: absolute; position: absolute;
left: 52px; left: 0;
right: 50px; /* between label and value cells */ right: 0;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
height: 18px; height: 18px;
@@ -204,9 +204,17 @@
transform: scale(0.98); transform: scale(0.98);
} }
.dash-cust-row.is-drop-target { .dash-cust-row.is-drop-target-before,
border-color: var(--ch-signal, var(--primary-color)); .dash-cust-row.is-drop-target-after {
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent); 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 { .dash-cust-row-fixed {
+7 -2
View File
@@ -336,8 +336,13 @@
/* Custom card icon plate /* Custom card icon plate
A 44x44 instrument-panel face plate at the leading edge of the A 44x44 instrument-panel face plate at the leading edge of the
head row. Channel-tinted; clickable to open the icon picker. head row. Channel-tinted; clickable to open the icon picker.
Renders only when ModHeadOpts.iconHtml is supplied. */ Renders only when ModHeadOpts.iconHtml is supplied.
.mod-head--with-icon { align-items: stretch; }
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 { .mod-icon {
--plate-size: 44px; --plate-size: 44px;
+13
View File
@@ -5385,6 +5385,19 @@ body.composite-layer-dragging .composite-layer-drag-handle {
background: transparent !important; background: transparent !important;
color: var(--lux-ink-mute, var(--text-secondary)) !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-meta { flex: 1; min-width: 0; }
.icon-picker-eyebrow { .icon-picker-eyebrow {
@@ -369,8 +369,10 @@ export function renderModFader(f: ModFaderOpts): string {
const disabledAttr = f.disabled ? ' disabled' : ''; const disabledAttr = f.disabled ? ' disabled' : '';
return `<div class="mod-fader"> return `<div class="mod-fader">
<span class="mod-fader__k">${escapeHtml(f.label)}</span> <span class="mod-fader__k">${escapeHtml(f.label)}</span>
<div class="mod-fader__track"><div class="mod-fader__fill" style="width:${pct}%"></div></div> <div class="mod-fader__lane">
<input type="range" class="mod-fader__slider" min="0" max="${max}" value="${f.value}"${sliderId} ${dataAttrs}${oninputAttr}${onchangeAttr}${disabledAttr}> <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> <span class="mod-fader__v">${f.value}</span>
</div>`; </div>`;
} }
@@ -1272,6 +1272,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
picture: { picture: {
load(css, sourceSelect) { load(css, sourceSelect) {
sourceSelect.value = css.picture_source_id || ''; sourceSelect.value = css.picture_source_id || '';
if (_cssPictureSourceEntitySelect) _cssPictureSourceEntitySelect.setValue(sourceSelect.value);
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average'; (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average'); if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
_ensureSmoothingWidget().setValue(css.smoothing); _ensureSmoothingWidget().setValue(css.smoothing);
@@ -89,6 +89,15 @@ const PERF_CELL_LABEL_KEYS: Record<string, string> = {
}; };
let _unsubscribe: (() => void) | null = null; 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 { export function openDashboardCustomize(): void {
let panel = document.getElementById(PANEL_ID); let panel = document.getElementById(PANEL_ID);
@@ -101,7 +110,10 @@ export function openDashboardCustomize(): void {
if (backdrop) backdrop.classList.add('is-open'); if (backdrop) backdrop.classList.add('is-open');
_renderPanelBody(); _renderPanelBody();
if (!_unsubscribe) { if (!_unsubscribe) {
_unsubscribe = subscribeDashboardLayout(() => _renderPanelBody()); _unsubscribe = subscribeDashboardLayout(() => {
if (_activeDrag) { _renderDeferred = true; return; }
_renderPanelBody();
});
} }
} }
@@ -161,6 +173,11 @@ function _renderPanelBody(): void {
${_renderActions()} ${_renderActions()}
`; `;
_bindHandlers(body); _bindHandlers(body);
if (_focusAfterRender) {
const el = body.querySelector<HTMLElement>(_focusAfterRender);
el?.focus();
_focusAfterRender = null;
}
} }
// ── Sub-renderers ──────────────────────────────────────────────────────── // ── Sub-renderers ────────────────────────────────────────────────────────
@@ -398,6 +415,7 @@ function _bindHandlers(root: HTMLElement): void {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const key = btn.dataset.sectionKey!; const key = btn.dataset.sectionKey!;
const dir = btn.dataset.move as 'up' | 'down'; const dir = btn.dataset.move as 'up' | 'down';
_focusAfterRender = `[data-section-key="${key}"][data-move="${dir}"]`;
_moveSection(key, dir); _moveSection(key, dir);
}); });
}); });
@@ -444,6 +462,7 @@ function _bindHandlers(root: HTMLElement): void {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const key = btn.dataset.cellKey!; const key = btn.dataset.cellKey!;
const dir = btn.dataset.cellMove as 'up' | 'down'; const dir = btn.dataset.cellMove as 'up' | 'down';
_focusAfterRender = `[data-cell-key="${key}"][data-cell-move="${dir}"]`;
_movePerfCell(key, dir); _movePerfCell(key, dir);
}); });
}); });
@@ -503,6 +522,21 @@ function _movePerfCell(key: string, dir: 'up' | 'down'): void {
} }
// ── Hand-rolled drag-and-drop sort ────────────────────────────────────── // ── 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( function _bindDragSort(
root: HTMLElement, root: HTMLElement,
@@ -512,42 +546,159 @@ function _bindDragSort(
): void { ): void {
const list = root.querySelector<HTMLElement>(listSelector); const list = root.querySelector<HTMLElement>(listSelector);
if (!list) return; if (!list) return;
let dragKey: string | null = null;
list.querySelectorAll<HTMLElement>('.dash-cust-row-drag').forEach(row => { let dragKey: string | null = null;
row.addEventListener('dragstart', (e) => { let dragRow: HTMLElement | null = null;
dragKey = row.getAttribute(keyAttr); let lastIndicatorRow: HTMLElement | null = null;
row.classList.add('is-dragging'); let lastIndicatorPos: 'before' | 'after' | null = null;
if (e.dataTransfer) { let autoScrollRaf: number | null = null;
e.dataTransfer.effectAllowed = 'move'; let autoScrollDir: -1 | 0 | 1 = 0;
// Required by Firefox to enable drag.
e.dataTransfer.setData('text/plain', dragKey || ''); 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; const getRowFrom = (target: EventTarget | null): HTMLElement | null => {
list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target')); const el = target as HTMLElement | null;
}); if (!el) return null;
row.addEventListener('dragover', (e) => { const row = el.closest<HTMLElement>('.dash-cust-row-drag');
if (!dragKey) return; return row && list.contains(row) ? row : null;
e.preventDefault(); };
list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target'));
row.classList.add('is-drop-target'); list.addEventListener('dragstart', (e) => {
}); // Don't start drag from interactive controls inside the row.
row.addEventListener('drop', (e) => { const interactive = (e.target as HTMLElement | null)
e.preventDefault(); ?.closest('button, select, input, textarea, a');
const targetKey = row.getAttribute(keyAttr); if (interactive) { e.preventDefault(); return; }
if (!dragKey || !targetKey || dragKey === targetKey) return;
const allRows = Array.from(list.querySelectorAll<HTMLElement>('.dash-cust-row-drag')); const row = getRowFrom(e.target);
const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || ''); if (!row) return;
const fromIdx = orderedKeys.indexOf(dragKey);
const toIdx = orderedKeys.indexOf(targetKey); dragKey = row.getAttribute(keyAttr);
if (fromIdx < 0 || toIdx < 0) return; dragRow = row;
const [moved] = orderedKeys.splice(fromIdx, 1); row.classList.add('is-dragging');
orderedKeys.splice(toIdx, 0, moved); _activeDrag = true;
onReorder(orderedKeys.filter(Boolean)); 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 { 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 * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect } from '../core/icon-select.ts';
import { wrapCard } from '../core/card-colors.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, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -34,6 +35,7 @@ let _updateRateWidget: BindableScalarWidget | null = null;
let _transitionWidget: BindableScalarWidget | null = null; let _transitionWidget: BindableScalarWidget | null = null;
let _colorToleranceWidget: BindableScalarWidget | null = null; let _colorToleranceWidget: BindableScalarWidget | null = null;
let _minBrightnessThresholdWidget: BindableScalarWidget | null = null; let _minBrightnessThresholdWidget: BindableScalarWidget | null = null;
let _stopActionIconSelect: IconSelect | null = null;
class HALightEditorModal extends Modal { class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); } constructor() { super('ha-light-editor-modal'); }
@@ -47,6 +49,7 @@ class HALightEditorModal extends Modal {
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; } if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; } if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; } if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; }
if (_stopActionIconSelect) { _stopActionIconSelect.destroy(); _stopActionIconSelect = null; }
_destroyMappingEntitySelects(); _destroyMappingEntitySelects();
} }
@@ -60,6 +63,7 @@ class HALightEditorModal extends Modal {
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5', transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5', color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0', min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0',
stop_action: (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value,
mappings: _getMappingsJSON(), mappings: _getMappingsJSON(),
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []), tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
}; };
@@ -271,6 +275,22 @@ function _ensureColorToleranceWidget(): BindableScalarWidget {
return _colorToleranceWidget; 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 { function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget {
if (!_minBrightnessThresholdWidget) { if (!_minBrightnessThresholdWidget) {
_minBrightnessThresholdWidget = new BindableScalarWidget({ _minBrightnessThresholdWidget = new BindableScalarWidget({
@@ -344,6 +364,9 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
_ensureTransitionWidget().setValue(editData.transition ?? 0.5); _ensureTransitionWidget().setValue(editData.transition ?? 0.5);
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5); _ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
_ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0); _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 || ''; (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
// Fetch entities from the selected HA source before loading mappings // 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); _ensureTransitionWidget().setValue(0.5);
_ensureColorToleranceWidget().setValue(5); _ensureColorToleranceWidget().setValue(5);
_ensureMinBrightnessThresholdWidget().setValue(0); _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 = ''; (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
// Fetch entities from the first HA source // 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 transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5; const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5;
const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0; 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; const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
if (!name) { if (!name) {
@@ -444,6 +473,7 @@ export async function saveHALightEditor(): Promise<void> {
transition, transition,
color_tolerance: colorTolerance, color_tolerance: colorTolerance,
min_brightness_threshold: minBrightnessThreshold, min_brightness_threshold: minBrightnessThreshold,
stop_action: stopAction,
description, description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [], tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
}; };
@@ -1,16 +1,22 @@
/** /**
* Icon picker modal choose a custom icon for an entity card. * Icon picker modal choose a custom icon for an entity card.
* *
* Currently wired for devices (PATCH /devices/:id { icon, icon_color }). * Generic over entity types (devices, LED targets, ). The picker is
* The plumbing is generic so other entity types can opt in later by * opened by document-level click delegation matching:
* registering a new ``onApply`` handler. *
* 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 { Modal } from '../core/modal.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { showToast } from '../core/ui.ts'; import { showToast } from '../core/ui.ts';
import { devicesCache } from '../core/state.ts'; import { devicesCache, outputTargetsCache } from '../core/state.ts';
import { import {
DEVICE_ICONS, DEVICE_ICONS,
CATEGORIES, CATEGORIES,
@@ -25,12 +31,114 @@ import {
const RECENT_KEY = 'ledgrab.icon-picker.recent'; const RECENT_KEY = 'ledgrab.icon-picker.recent';
const RECENT_MAX = 10; 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 { interface PickerContext {
deviceId: string; entityType: EntityType;
entityId: string;
entityName: string;
initialIconId: string; initialIconId: string;
initialColor: string; initialColor: string;
/** CSS color used for the live channel preview (e.g. '#4CAF50'). */ /** CSS color used for the live channel preview (e.g. '#4CAF50'). */
channelColor: string; channelColor: string;
/** Optional inherited icon (LED target → device). */
inherited: InheritedIcon | null;
} }
let _ctx: PickerContext | null = null; let _ctx: PickerContext | null = null;
@@ -71,20 +179,33 @@ function _pushRecent(iconId: string): void {
// Public entry points // Public entry points
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
/** Open the picker for the given device. Reads current icon from cache. */ /** Open the picker for the given entity. Reads current icon from cache. */
export function openDeviceIconPicker(deviceId: string): void { export function openIconPicker(entityType: EntityType, entityId: string): void {
if (!deviceId) return; if (!entityId) return;
const device = (devicesCache.data ?? []).find((d: any) => d.id === deviceId) ?? null; const adapter = _adapters[entityType];
const initialIconId = (device?.icon as string | undefined) ?? ''; if (!adapter) return;
const initialColor = (device?.icon_color as string | undefined) ?? '';
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. // 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 const channelColor = card
? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel() ? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel()
: _fallbackChannel(); : _fallbackChannel();
_ctx = { deviceId, initialIconId, initialColor, channelColor }; _ctx = {
entityType,
entityId,
entityName: rec?.name ?? entityId,
initialIconId,
initialColor,
channelColor,
inherited,
};
_selectedIconId = initialIconId; _selectedIconId = initialIconId;
_selectedColor = initialColor; _selectedColor = initialColor;
_activeCategory = 'all'; _activeCategory = 'all';
@@ -96,13 +217,17 @@ export function openDeviceIconPicker(deviceId: string): void {
_renderModal(); _renderModal();
_modalInstance.open(); _modalInstance.open();
// Focus search after open — done in the next frame so the modal is visible.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const search = document.getElementById('icon-picker-search') as HTMLInputElement | null; const search = document.getElementById('icon-picker-search') as HTMLInputElement | null;
search?.focus(); 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. */ /** Close the picker without applying changes. */
export function closeIconPicker(): void { export function closeIconPicker(): void {
_modalInstance?.close(); _modalInstance?.close();
@@ -118,11 +243,29 @@ function _fallbackChannel(): string {
return (root.getPropertyValue('--ch-signal') || '#4CAF50').trim(); 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 { function _renderModal(): void {
if (!_ctx) return; if (!_ctx) return;
const previewEl = document.getElementById('icon-picker-preview') as HTMLElement | null; const previewEl = document.getElementById('icon-picker-preview') as HTMLElement | null;
const titleNameEl = document.getElementById('icon-picker-device-name') 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 swatchEl = document.getElementById('icon-picker-swatch') as HTMLElement | null;
const tabsEl = document.getElementById('icon-picker-tabs') as HTMLElement | null; const tabsEl = document.getElementById('icon-picker-tabs') as HTMLElement | null;
const recentEl = document.getElementById('icon-picker-recent') 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; if (!previewEl || !tabsEl || !gridEl) return;
// Resolve effective color for the preview (override > channel) const display = _resolveDisplayIcon();
const effectiveColor = _selectedColor || _ctx.channelColor; const effectiveColor = display.color;
previewEl.style.setProperty('--ch', effectiveColor); previewEl.style.setProperty('--ch', effectiveColor);
previewEl.style.color = effectiveColor; previewEl.style.color = effectiveColor;
previewEl.innerHTML = _selectedIconId previewEl.classList.toggle('is-inherited', display.isInherited && !!display.iconId);
? renderDeviceIconSvg(_selectedIconId, { size: 30 }) previewEl.classList.toggle('is-empty', !display.iconId);
: `<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>`; previewEl.innerHTML = display.iconId
if (!_selectedIconId) previewEl.classList.add('is-empty'); ? renderDeviceIconSvg(display.iconId, { size: 30 })
else previewEl.classList.remove('is-empty'); : `<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 // Header — entity type + name, plus inherited hint when applicable.
if (titleNameEl) { const adapter = _adapters[_ctx.entityType];
const device = (devicesCache.data ?? []).find((d: any) => d.id === _ctx!.deviceId); if (eyebrowEl) {
titleNameEl.textContent = device?.name ?? _ctx.deviceId; 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 // Swatch reflects current effective color
@@ -154,15 +308,12 @@ function _renderModal(): void {
swatchEl.style.borderColor = effectiveColor; swatchEl.style.borderColor = effectiveColor;
} }
// Search input value (only set if not focused — preserves caret)
if (searchEl && document.activeElement !== searchEl) { if (searchEl && document.activeElement !== searchEl) {
searchEl.value = _query; searchEl.value = _query;
} }
// Tabs
tabsEl.innerHTML = _renderTabsHtml(); tabsEl.innerHTML = _renderTabsHtml();
// Recent strip
if (recentEl) { if (recentEl) {
const recent = _readRecent(); const recent = _readRecent();
if (recent.length === 0) { if (recent.length === 0) {
@@ -176,12 +327,20 @@ function _renderModal(): void {
} }
} }
// Grid (filtered + grouped or flat depending on query/category)
gridEl.innerHTML = _renderGridHtml(); 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) { 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>`; 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') { if (_query || _activeCategory !== 'all') {
return `<div class="icon-picker-grid">${inCat.map(_iconTileHtml).join('')}</div>`; 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; if (!_ctx) return;
const { deviceId, initialIconId, initialColor } = _ctx; const { entityType, entityId, initialIconId, initialColor } = _ctx;
if (_selectedIconId === initialIconId && _selectedColor === initialColor) { if (nextIconId === initialIconId && nextColor === initialColor) {
closeIconPicker(); closeIconPicker();
return; return;
} }
const adapter = _adapters[entityType];
try { 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', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify(body),
icon: _selectedIconId,
icon_color: _selectedColor,
}),
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); 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; return;
} }
if (_selectedIconId) _pushRecent(_selectedIconId); if (nextIconId) _pushRecent(nextIconId);
showToast(t('device.icon.saved') || 'Icon saved', 'success'); showToast(t('device.icon.saved') || 'Icon saved', 'success');
devicesCache.invalidate(); await adapter.reload();
await window.loadDevices?.();
closeIconPicker(); closeIconPicker();
} catch (error: any) { } catch (error: any) {
if (error?.isAuth) return; if (error?.isAuth) return;
@@ -269,11 +431,14 @@ async function _applyToDevice(): Promise<void> {
} }
} }
async function _removeIcon(): Promise<void> { async function _applyCurrentSelection(): Promise<void> {
if (!_ctx) return; await _applyChange(_selectedIconId, _selectedColor);
_selectedIconId = ''; }
_selectedColor = '';
await _applyToDevice(); 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 { function _selectIcon(iconId: string): void {
@@ -288,30 +453,22 @@ function _setCategory(cat: IconCategory | 'all'): void {
function _setQuery(q: string): void { function _setQuery(q: string): void {
_query = q; _query = q;
// Switch to "all" tab when a query is typed so search reaches all icons.
if (q && _activeCategory !== 'all') _activeCategory = 'all'; if (q && _activeCategory !== 'all') _activeCategory = 'all';
_renderModal(); _renderModal();
} }
function _toggleColorOverride(): void { function _toggleColorOverride(): void {
if (!_ctx) return; 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) { if (_selectedColor) {
_selectedColor = ''; _selectedColor = '';
} else { } 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; _selectedColor = _ctx.channelColor;
} }
_renderModal(); _renderModal();
} }
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Event delegation — bound once on first import // Modal-internal event wiring
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
let _wired = false; let _wired = false;
@@ -343,7 +500,7 @@ function _wireEvents(): void {
return; return;
} }
if (target.closest('#icon-picker-apply')) { if (target.closest('#icon-picker-apply')) {
_applyToDevice(); _applyCurrentSelection();
return; return;
} }
if (target.closest('#icon-picker-cancel') || target.closest('.icon-picker-close')) { if (target.closest('#icon-picker-cancel') || target.closest('.icon-picker-close')) {
@@ -351,7 +508,7 @@ function _wireEvents(): void {
return; return;
} }
if (target.closest('#icon-picker-remove')) { if (target.closest('#icon-picker-remove')) {
_removeIcon(); _removeOwnIcon();
return; return;
} }
}); });
@@ -364,12 +521,11 @@ function _wireEvents(): void {
root.addEventListener('keydown', (e) => { root.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'BUTTON') { if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'BUTTON') {
e.preventDefault(); e.preventDefault();
_applyToDevice(); _applyCurrentSelection();
} }
}); });
} }
// Wire as soon as the DOM is ready (or immediately if it already is).
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _wireEvents, { once: true }); document.addEventListener('DOMContentLoaded', _wireEvents, { once: true });
} else { } else {
@@ -377,19 +533,22 @@ if (document.readyState === 'loading') {
} }
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Document-level click delegation — opens the picker for any element // Document-level click delegation. Triggers match
// matching ``[data-icon-picker-trigger="<deviceId>"]`` (the icon plate // ``[data-icon-picker-trigger="<entityType>:<entityId>"]``. Legacy
// on each card and the "Change icon…" item in the kebab menu). Avoids // triggers without a colon default to ``device:<id>`` so older cards
// polluting ``window`` with an inline onclick target. // keep working during the rollout.
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
function _onDocumentClick(e: MouseEvent): void { function _onDocumentClick(e: MouseEvent): void {
const el = (e.target as HTMLElement | null)?.closest('[data-icon-picker-trigger]') as HTMLElement | null; const el = (e.target as HTMLElement | null)?.closest('[data-icon-picker-trigger]') as HTMLElement | null;
if (!el) return; if (!el) return;
const deviceId = el.getAttribute('data-icon-picker-trigger') || ''; const raw = el.getAttribute('data-icon-picker-trigger') || '';
if (!deviceId) return; if (!raw) return;
const [typeOrId, id] = raw.includes(':') ? raw.split(':', 2) : ['device', raw];
if (!id) return;
if (typeOrId !== 'device' && typeOrId !== 'target') return;
e.stopPropagation(); e.stopPropagation();
openDeviceIconPicker(deviceId); openIconPicker(typeOrId as EntityType, id);
} }
document.addEventListener('click', _onDocumentClick); document.addEventListener('click', _onDocumentClick);
@@ -30,8 +30,9 @@ import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.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 { TagInput, renderTagChips } from '../core/tag-input.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts'; import { createFpsSparkline } from '../core/chart-utils.ts';
import { CardSection } from '../core/card-sections.ts'; import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.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. ── // instrument routed through the device. ──
const badgeText = 'LED · TGT'; const badgeText = 'LED · TGT';
// ── Meta line: device link · protocol · target FPS · pixel count. // ── Meta line: protocol · target FPS · pixel count.
// Protocol carries device-type-specific richness (OpenRGB SDK, // The device link used to live here too; it now appears as a
// Adalight serial, etc.) — _protocolBadge() returns icon + label. ── // content chip below (mirrors how the color-strip-source link is
const deviceLink = target.device_id // rendered, and gives space for the device's custom icon). ──
? `<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);
const targetFps = bindableValue(target.fps, 30); 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; const ledCount = device?.state?.device_led_count || device?.led_count;
if (ledCount) metaParts.push(`${ledCount} px`); if (ledCount) metaParts.push(`${ledCount} px`);
const metaHtml = metaParts.join(' · '); const metaHtml = metaParts.join(' · ');
// ── Chips: CSS source link, brightness override, threshold ── // ── Chips: device link, CSS source link, brightness override, threshold ──
const chips: ModChipOpts[] = []; 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) { if (cssSource) {
chips.push({ chips.push({
icon: ICON_FILM, icon: ICON_FILM,
@@ -1163,6 +1178,29 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
title: t('common.edit'), 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 = { const mod: ModCardOpts = {
head: { head: {
badge: { text: badgeText }, badge: { text: badgeText },
@@ -1170,7 +1208,12 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
metaHtml, metaHtml,
healthDot, healthDot,
leds, leds,
iconHtml,
iconColor: effectiveIconColor,
iconAttrs: { 'data-icon-picker-trigger': `target:${target.id}` },
iconTitle,
menu: { menu: {
extraItems: targetMenuExtraItems,
duplicateOnclick: `cloneTarget('${target.id}')`, duplicateOnclick: `cloneTarget('${target.id}')`,
hideOnclick: `toggleCardHidden('led-targets','${target.id}')`, hideOnclick: `toggleCardHidden('led-targets','${target.id}')`,
deleteOnclick: `deleteTarget('${target.id}')`, deleteOnclick: `deleteTarget('${target.id}')`,
+7
View File
@@ -105,6 +105,13 @@ interface OutputTargetBase {
target_type: TargetType; target_type: TargetType;
description?: string; description?: string;
tags: 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; created_at: string;
updated_at: string; updated_at: string;
} }
+13
View File
@@ -582,6 +582,11 @@
"device.icon.cat.media": "Media", "device.icon.cat.media": "Media",
"device.icon.cat.signal": "Signal", "device.icon.cat.signal": "Signal",
"device.icon.cat.ambience": "Ambience", "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", "validation.required": "This field is required",
"bulk.processing": "Processing…", "bulk.processing": "Processing…",
"api.error.timeout": "Request timed out — please try again", "api.error.timeout": "Request timed out — please try again",
@@ -2198,6 +2203,14 @@
"ha_light.updated": "HA light target updated", "ha_light.updated": "HA light target updated",
"ha_light.mapping.select_entity": "Select a light entity...", "ha_light.mapping.select_entity": "Select a light entity...",
"ha_light.mapping.search_entity": "Search light entities...", "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.", "section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
"automations.rule.home_assistant": "Home Assistant", "automations.rule.home_assistant": "Home Assistant",
"automations.rule.home_assistant.desc": "HA entity state", "automations.rule.home_assistant.desc": "HA entity state",
+13
View File
@@ -6,6 +6,14 @@
"ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.", "ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.",
"ha_light.min_brightness_threshold": "Мин. порог яркости:", "ha_light.min_brightness_threshold": "Мин. порог яркости:",
"ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).", "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.version": "Версия:",
"app.api_docs": "Документация API", "app.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен", "app.connection_lost": "Сервер недоступен",
@@ -586,6 +594,11 @@
"device.icon.cat.media": "Медиа", "device.icon.cat.media": "Медиа",
"device.icon.cat.signal": "Сигнал", "device.icon.cat.signal": "Сигнал",
"device.icon.cat.ambience": "Атмосфера", "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": "Обязательное поле", "validation.required": "Обязательное поле",
"bulk.processing": "Обработка…", "bulk.processing": "Обработка…",
"api.error.timeout": "Превышено время ожидания — попробуйте снова", "api.error.timeout": "Превышено время ожидания — попробуйте снова",
+13
View File
@@ -6,6 +6,14 @@
"ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。", "ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。",
"ha_light.min_brightness_threshold": "最低亮度阈值:", "ha_light.min_brightness_threshold": "最低亮度阈值:",
"ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。", "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.version": "版本:",
"app.api_docs": "API 文档", "app.api_docs": "API 文档",
"app.connection_lost": "服务器不可达", "app.connection_lost": "服务器不可达",
@@ -586,6 +594,11 @@
"device.icon.cat.media": "媒体", "device.icon.cat.media": "媒体",
"device.icon.cat.signal": "信号", "device.icon.cat.signal": "信号",
"device.icon.cat.ambience": "氛围", "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": "此字段为必填项", "validation.required": "此字段为必填项",
"bulk.processing": "处理中…", "bulk.processing": "处理中…",
"api.error.timeout": "请求超时 — 请重试", "api.error.timeout": "请求超时 — 请重试",
@@ -36,6 +36,9 @@ class HALightMapping:
) )
VALID_STOP_ACTIONS = ("none", "turn_off", "restore")
@dataclass @dataclass
class HALightOutputTarget(OutputTarget): class HALightOutputTarget(OutputTarget):
"""Output target that casts LED colors to Home Assistant lights via service calls.""" """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)) transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0)) min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.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: def register_with_manager(self, manager) -> None:
"""Register this HA light target with the processor manager.""" """Register this HA light target with the processor manager."""
@@ -62,6 +66,7 @@ class HALightOutputTarget(OutputTarget):
transition=self.transition, transition=self.transition,
min_brightness_threshold=self.min_brightness_threshold, min_brightness_threshold=self.min_brightness_threshold,
color_tolerance=self.color_tolerance, color_tolerance=self.color_tolerance,
stop_action=self.stop_action,
) )
def sync_with_manager( def sync_with_manager(
@@ -85,6 +90,7 @@ class HALightOutputTarget(OutputTarget):
"min_brightness_threshold": self.min_brightness_threshold, "min_brightness_threshold": self.min_brightness_threshold,
"color_tolerance": self.color_tolerance, "color_tolerance": self.color_tolerance,
"light_mappings": self.light_mappings, "light_mappings": self.light_mappings,
"stop_action": self.stop_action,
}, },
) )
if css_changed: if css_changed:
@@ -104,12 +110,21 @@ class HALightOutputTarget(OutputTarget):
transition=None, transition=None,
min_brightness_threshold=None, min_brightness_threshold=None,
color_tolerance=None, color_tolerance=None,
stop_action=None,
description=None, description=None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
**_kwargs, **_kwargs,
) -> None: ) -> None:
"""Apply mutable field updates.""" """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: if ha_source_id is not None:
self.ha_source_id = resolve_ref(ha_source_id, self.ha_source_id) self.ha_source_id = resolve_ref(ha_source_id, self.ha_source_id)
if color_strip_source_id is not None: if color_strip_source_id is not None:
@@ -135,6 +150,8 @@ class HALightOutputTarget(OutputTarget):
) )
if color_tolerance is not None: if color_tolerance is not None:
self.color_tolerance = self.color_tolerance.apply_update(color_tolerance) 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: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -146,6 +163,7 @@ class HALightOutputTarget(OutputTarget):
d["transition"] = self.transition.to_dict() d["transition"] = self.transition.to_dict()
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict() d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
d["color_tolerance"] = self.color_tolerance.to_dict() d["color_tolerance"] = self.color_tolerance.to_dict()
d["stop_action"] = self.stop_action
return d return d
@classmethod @classmethod
@@ -171,8 +189,13 @@ class HALightOutputTarget(OutputTarget):
data.get("min_brightness_threshold"), default=0.0 data.get("min_brightness_threshold"), default=0.0
), ),
color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.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"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat( created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat()) data.get("created_at", datetime.now(timezone.utc).isoformat())
), ),
+17 -1
View File
@@ -16,6 +16,11 @@ class OutputTarget:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) 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: def register_with_manager(self, manager) -> None:
"""Register this target with the processor manager. Subclasses override.""" """Register this target with the processor manager. Subclasses override."""
@@ -34,6 +39,8 @@ class OutputTarget:
device_id=None, device_id=None,
description=None, description=None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
**_kwargs, **_kwargs,
) -> None: ) -> None:
"""Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones.""" """Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones."""
@@ -43,10 +50,14 @@ class OutputTarget:
self.description = description self.description = description
if tags is not None: if tags is not None:
self.tags = tags 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: def to_dict(self) -> dict:
"""Convert to dictionary.""" """Convert to dictionary."""
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"target_type": self.target_type, "target_type": self.target_type,
@@ -55,6 +66,11 @@ class OutputTarget:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_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 @classmethod
def from_dict(cls, data: dict) -> "OutputTarget": def from_dict(cls, data: dict) -> "OutputTarget":
@@ -54,6 +54,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
update_rate: float = 2.0, update_rate: float = 2.0,
transition=None, transition=None,
color_tolerance: int = 5, color_tolerance: int = 5,
stop_action: str = "none",
# legacy compat # legacy compat
brightness_value_source_id: str = "", brightness_value_source_id: str = "",
) -> OutputTarget: ) -> OutputTarget:
@@ -126,6 +127,9 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold, default=0.0 min_brightness_threshold, default=0.0
), ),
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.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, description=description,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -155,11 +159,14 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
protocol=None, protocol=None,
description=None, description=None,
tags=None, tags=None,
icon=None,
icon_color=None,
ha_source_id=None, ha_source_id=None,
ha_light_mappings=None, ha_light_mappings=None,
update_rate=None, update_rate=None,
transition=None, transition=None,
color_tolerance=None, color_tolerance=None,
stop_action=None,
# legacy compat # legacy compat
brightness_value_source_id=None, brightness_value_source_id=None,
) -> OutputTarget: ) -> OutputTarget:
@@ -193,11 +200,14 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
protocol=protocol, protocol=protocol,
description=description, description=description,
tags=tags, tags=tags,
icon=icon,
icon_color=icon_color,
ha_source_id=ha_source_id, ha_source_id=ha_source_id,
light_mappings=ha_light_mappings, light_mappings=ha_light_mappings,
update_rate=update_rate, update_rate=update_rate,
transition=transition, transition=transition,
color_tolerance=color_tolerance, color_tolerance=color_tolerance,
stop_action=stop_action,
) )
target.updated_at = datetime.now(timezone.utc) target.updated_at = datetime.now(timezone.utc)
@@ -83,10 +83,18 @@ class WledOutputTarget(OutputTarget):
protocol=None, protocol=None,
description=None, description=None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
**_kwargs, **_kwargs,
) -> None: ) -> None:
"""Apply mutable field updates for WLED targets.""" """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: if device_id is not None:
self.device_id = resolve_ref(device_id, self.device_id) self.device_id = resolve_ref(device_id, self.device_id)
if color_strip_source_id is not None: if color_strip_source_id is not None:
@@ -159,6 +167,8 @@ class WledOutputTarget(OutputTarget):
protocol=data.get("protocol", "ddp"), protocol=data.get("protocol", "ddp"),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat( created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat()) 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> <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 id="ha-light-editor-brightness-container"></div>
</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> </div>
</section> </section>
@@ -10,12 +10,11 @@
<div id="icon-picker-preview" class="mod-icon icon-picker-preview" aria-hidden="true"></div> <div id="icon-picker-preview" class="mod-icon icon-picker-preview" aria-hidden="true"></div>
</div> </div>
<div class="icon-picker-meta"> <div class="icon-picker-meta">
<div class="icon-picker-eyebrow" data-i18n="device.icon.eyebrow">Card icon</div> <div id="icon-picker-eyebrow" class="icon-picker-eyebrow">Card icon</div>
<h2 id="icon-picker-title" class="icon-picker-title" data-i18n="device.icon.title">Choose an icon</h2> <h2 id="icon-picker-title" class="icon-picker-title">
<div class="icon-picker-sub">
<span data-i18n="device.icon.for">for</span>
<strong id="icon-picker-device-name"></strong> <strong id="icon-picker-device-name"></strong>
</div> </h2>
<div id="icon-picker-sub" class="icon-picker-sub"></div>
</div> </div>
<button class="modal-close-btn icon-picker-close" type="button" data-i18n-aria-label="aria.close" aria-label="Close">&#x2715;</button> <button class="modal-close-btn icon-picker-close" type="button" data-i18n-aria-label="aria.close" aria-label="Close">&#x2715;</button>
</div> </div>