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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}')`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Превышено время ожидания — попробуйте снова",
|
||||
|
||||
@@ -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())
|
||||
),
|
||||
|
||||
@@ -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 (0–1). 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">✕</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user