Compare commits

...

9 Commits

Author SHA1 Message Date
alexei.dolgolyov 09792a9a05 chore: release v0.6.1
Build Release / create-release (push) Successful in 4s
Build Android APK / build-android (push) Failing after 9s
Build Release / build-linux (push) Successful in 2m13s
Build Release / build-docker (push) Successful in 3m9s
Build Release / build-windows (push) Successful in 4m6s
2026-05-10 23:57:47 +03:00
alexei.dolgolyov 75ca487be1 feat(ui): per-surface card presentation modes (C/M/D/R)
Adds a comfortable/compact/dense/row toggle to every card grid in the
app. Each surface (LED devices, targets, automations, scenes, sources,
streams, dashboard subsections, etc.) remembers its mode independently.

Persistence mirrors dashboard-layout: localStorage cache for first paint,
debounced PUT to /api/v1/preferences/card-modes (new endpoint) for
cross-browser sync. Surface registry is open — any non-empty key
accepted server-side; modes validated against {comfortable, compact,
dense, row}.

CSS is token-driven: grid min-width and gap come from --card-grid-min /
--card-grid-gap / --card-grid-min-narrow / --card-grid-gap-narrow /
--templates-grid-min / --templates-grid-gap defined on :root, overridden
per [data-card-mode]. Dense/row also hide .mod-leds, collapse secondary
button labels, and tighten .mod-metrics; row collapses the grid to one
full-width column. Coexists with the existing per-section [data-density]
on the dashboard tab — different attribute, additive concern.

Toggle UI auto-mounts into every CardSection header (18+ surfaces) plus
the six dashboard subsections via post-render mount; teardown tracking
keeps the listener Set bounded across re-renders.

i18n: card_mode.{tooltip,comfortable,compact,dense,row} in en/ru/zh.
Tests: 9 new cases in tests/test_preferences_card_modes_api.py covering
defaults, round-trip, validation, open-registry keys, row mode, delete.
2026-05-10 23:49:14 +03:00
alexei.dolgolyov e65dcb41f4 chore: clean up cfg abbreviation and stale TODO link
Rename `cfg` parameter/local in resolve_mqtt_password to `config`
for PEP 8 compliance. Drop the broken reference to the long-removed
docs/plans/device-typed-configs.md from TODO.md.
2026-05-10 23:19:15 +03:00
alexei.dolgolyov 6a07a6b1a2 fix(shutdown): apply target stop actions before tearing down HA/MQTT
Reorder the lifespan shutdown so processor_manager.stop_all() runs before
ha_manager.shutdown(), mqtt_manager.shutdown(), and mqtt_service.stop().
HA-light targets check `_ha_runtime.is_connected` before applying their
`stop_action` (turn_off / restore) and silently skip when HA is already
disconnected; MQTT-output devices need the broker connection alive to
send restore frames. The previous order tore those down first, turning
"stop_targets" into a no-op for those targets — most visible when
closing via the tray Shutdown button.

Also moves automation_engine.stop(), discovery_watcher.stop(), and the
OS notification listener stop ahead of processor stop so they can no
longer fire events into a shutting-down processor manager. Independent
services (weather, update checker, auto-backup) now run last, where
their order does not matter.

Bonus: if the daemon-thread join times out (10 s) and the rest of
shutdown is cut short, the user-visible part — targets stopping — has
already run.
2026-05-10 22:39:18 +03:00
alexei.dolgolyov 0f5850ef80 feat(ui): customisable card icon for all entity types
Extends the icon-plate work from devices and output targets to every
remaining card type — 18 new entities, 20 in total. Users can now pick
a curated icon (with optional colour override) for any card on any tab,
and the picker reuses the same modal, recent-strip, search, and
category tabs introduced for the device picker.

Foundation:
- icon-picker.ts — replace the hardcoded 2-entry adapter record with a
  Map<EntityType, EntityTypeAdapter> and expose
  registerIconEntityType() + makeSimpleIconAdapter() so each feature
  module owns its own adapter (~6 lines per type).
- bodyExtras hook on adapters, keyed off id, lets discriminated routes
  (output-targets target_type, picture-sources stream_type, audio /
  value / color-strip-sources source_type) accept icon-only PUTs.
- core/card-icon.ts — new makeCardIconFields(type, id, entity) helper
  spreads iconHtml / iconColor / iconAttrs into a mod-card head in one
  line.
- _onDocumentClick now accepts any registered type instead of a
  hardcoded device/target check.

Backend (purely additive — no migrations needed thanks to JSON-blob
storage):
- 18 dataclasses gained icon: str = "" + icon_color: str = "" with
  emit-when-truthy serialisation and "" defaults on load.
- All matching Create / Update / Response Pydantic schemas gained the
  fields with the standard Optional[str] + max_length=64/32 +
  description set.
- All routes' response builders use
  getattr(entity, "icon", "") or "" so existing rows render unchanged.
- ValueSource and CSS handle icon/icon_color on the base class so all
  source-type subclasses inherit them automatically.

Frontend wiring (12 modules):
- streams.ts — picture sources, capture templates, PP templates,
  CSPT, audio sources, audio templates, gradients (built-in
  gradients keep no plate).
- automations, scene-presets, sync-clocks, weather-sources,
  value-sources, mqtt-sources, home-assistant-sources,
  game-integration, audio-processing-templates, assets,
  color-strips/cards.
- pattern-templates skipped — uses the legacy wrapCard({content,
  actions}) string API, separate migration.

Dashboard cards now also display the chosen icon:
- Targets already had it (with device inheritance for LED targets).
- Sync clocks, automations, and scene presets gained the same plate
  via a shared _dashboardIconPlate helper that mirrors the mod-card
  layout (mod-head--with-icon class flips on when present).

i18n: 20 new device.icon.entity.<type> labels in en/ru/zh.

Verification:
- ruff check src/ tests/ — clean.
- npx tsc --noEmit — clean.
- npm run build — 2.6 MB bundle.
- pytest tests/ --no-cov — 949 passed (no regressions).

Pending: manual smoke test on each card type — open picker, save, and
confirm the channel-color preview matches the live card.
2026-05-09 16:19:20 +03:00
alexei.dolgolyov a79f4bf73c feat(ha-light): broadcast a single Color Value Source to all entities
HALightOutputTarget gains a `source_kind` field with two modes:
- `css` (existing): per-mapping LED segments averaged from a ColorStripSource.
- `color_vs` (new): one colour from a colour-returning ValueSource pushed to
  every mapped entity (mapping LED ranges are ignored in this mode).

Backend wiring:
- Schema/route: add `source_kind` + `color_value_source_id` to create/update/
  response payloads, with VS existence + return_type=color validation.
- Storage: persist new fields, with defensive `or ""` coalesce so legacy rows
  written via resolve_ref with None survive the str-typed response schema.
- Processor: ha_light_target_processor reworked to drive both source kinds
  (incl. update_target_settings hot-swap of source mode). New unit tests in
  tests/core/test_ha_light_target_processor.py and extended store tests.

Frontend:
- ha-light editor modal: collapsed Color Strip + Color VS into one
  "Color Source" picker with grouped headers; mappings list shows a
  mode-aware hint when broadcasting a single colour.
- EntityPalette: support non-selectable header rows (with keyboard / filter
  handling) for grouped source pickers.

Bundled UI polish (icon inheritance + cleanup):
- Custom card icons now flow into more surfaces: command palette, dashboard
  target cards, scene-preset target picker, calibration test-device picker,
  and the LED-target device picker. LED targets inherit their device's icon
  when none is set on the target itself.
- Empty mod-card icon plates render as a dashed "+" placeholder when an
  icon-picker hook is wired, so the action stays discoverable.
- Icon picker: distinct "HA light target" eyebrow label and supports
  HA-light cards (data-ha-target-id) for channel-colour resolution.
- Update banner: "View release" now opens the in-app Update settings tab
  instead of an external link; uses the sparkles icon.
- Color-strip delete: cleaner toast on 409 conflict.
2026-05-04 14:27:22 +03:00
alexei.dolgolyov ced72fc864 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.
2026-05-04 00:43:55 +03:00
alexei.dolgolyov 49ddabbc36 feat(ui): customisable card icon plate for devices
A user-chosen icon ("mouse", "motherboard", "keyboard"…) renders as a
44x44 instrument-panel face plate at the leading edge of .mod-head on
device cards. Optional per-card; null hides the plate and reverts to
the existing badge-only head.

- Storage/schema: new icon, icon_color fields on Device + DeviceUpdate /
  DeviceResponse. SQLite stores entities as a JSON blob, so no migration
  is needed; from_dict defaults handle existing rows.
- Curated 47-icon library across six categories (Hardware / Lighting /
  Rooms / Media / Signal / Ambience), reusing the existing Lucide path
  module; adds circuit-board, bed, armchair, leaf paths.
- mod-card.ts: ModHeadOpts gains iconHtml / iconColor / iconAttrs;
  ModMenuItemOpts gains optional dataAttrs. The plate is rendered when
  iconHtml is supplied; otherwise no layout change.
- Picker modal (icon-picker.html + features/icon-picker.ts): live
  preview, search, six category tabs, recent strip, channel-color
  override toggle. Wired through document-level click delegation on
  [data-icon-picker-trigger="<deviceId>"] — no window globals, no
  inline onclick string. Sets the precedent for migrating other card
  actions off window in a follow-up.
- en/ru/zh locales for picker UI + categories.

Includes a docs/ mockup that's the source-of-truth for the design.
2026-05-03 15:08:17 +03:00
alexei.dolgolyov a026f0b349 ci(android): fail-fast on missing release keystore before SDK setup
Move the keystore guard from after the Decode step (step 9) to right
after Resolve build label (step 3). A release tag pushed without
ANDROID_KEYSTORE_BASE64 configured now fails in seconds instead of
after JDK + Python + Android SDK + NDK install (~3-5 min of wasted
runner time). Switched the condition from steps.keystore.outputs.present
to env.ANDROID_KEYSTORE_BASE64 since the env var is set at job level
and the keystore decode step has not yet run at the new position.
2026-05-01 19:18:46 +03:00
148 changed files with 7436 additions and 417 deletions
+11 -9
View File
@@ -54,6 +54,17 @@ jobs:
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
echo "Build label: $LABEL (release=$IS_RELEASE)"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
# Runs before JDK/Python/SDK/NDK setup so a misconfigured release
# tag fails in seconds instead of after several minutes of setup.
if: ${{ steps.label.outputs.is_release == 'true' && env.ANDROID_KEYSTORE_BASE64 == '' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Setup JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
@@ -122,15 +133,6 @@ jobs:
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Build APK
working-directory: android
env:
+20 -40
View File
@@ -1,42 +1,28 @@
## v0.6.0 (2026-05-01)
This release adds **device-event notifications** (snack + Web Notifications), a **daylight/timezone-aware streaming pipeline** with a new camera engine, a **redesigned Targets surface** built on the dashboard's mod-card system, a **tighter LED hot path** with allocation-free per-frame work, and a **revamped Release Notes overlay** with clickable asset downloads. Plus a wide pass of modal, toolbar, and settings polish across the WebUI.
## v0.6.1 (2026-05-10)
### Features
- **Device event notifications** — configurable per-event channel matrix (none / snack / OS / both) for target online/offline, new WLED/serial discovery, and devices going missing. Backed by a long-running mDNS browser + 10 s serial poller, a startup-grace / flap-debounce / bulk-coalesce pipeline, and a new Notifications tab in Settings (en/ru/zh). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- **Daylight + timezone streaming** — new `daylight_settings` module and `daylight-tz` frontend helper expand the daylight stream's behavior; capture path additions land alongside a new **camera engine** test suite. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- **Targets cards migrated to the mod-card system** — LED targets and HA Light targets now share the dashboard's instrument-readout vocabulary (mod-head / mod-leds / mod-metrics / mod-foot, kebab menu, badges, chips, patch indicator). LED preview, FPS sparkline, and pipeline metrics preserved via an `extraHtml` escape hatch. ([233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463))
- **Target pipeline as a compact strip + chip row** — drops the legacy "Pipeline details" collapsible block; an always-visible 4 px segmented timing bar (extract / map / smooth / send for video, read / fft / render / send for audio) sits above an inline chip row showing total ms / frames / keepalives, animating smoothly between samples. ([51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2))
- **Targets metrics aligned with the dashboard** — FPS sparkline now lives inside the FPS cell, Uptime gets a clock icon, Errors gets ok/warning by count, FPS readout adopts the dashboard `current/target avg N.N` shape, and the grid sizes so values like `1m 43s` no longer truncate at typical desktop widths. ([9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2))
- **Release Notes overlay v2** — new masthead with display-font title, tag/published/pre-release chip strip, and close/external actions; markdown body fuzzy-matches `<code>` filenames to release assets and renders clickable download links with per-asset descriptions (Windows installer/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android apk/aab, iOS ipa). Checksum/signature side-files are hidden. ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
- **Tutorials expansion** — sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks let tours open/close the dashboard customize panel and resolve targets behind sub-tabs; new steps for integrations, dashboard customize panel (presets / global / sections / perf cells), targets, scenes, and sync-clocks (en/ru/zh). ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- **Cards / settings / modal / toolbar polish** — reworked mod-card colors, sections, channel-stripe styling, hairline borders, and signal-flow animation on running cards; multiselect bulk toolbar gets explicit Select-all / Deselect-all icons with luxury-gradient toolbar styling; Settings tabs are now icon-only (no overflow at any locale); modal exit animation gains symmetric fadeOut + slideDown keyframes with reduced-motion support; locale picker collapses to EN / RU / ZH; snack toast adopts a glass background with per-type accent. ([a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b))
- **Suppress browser auto-open on Windows login** — when "Start with Windows" is enabled, the autostart shortcut now passes `--autostart` so the WebUI tab no longer pops on every login. Manual launches and the installer's "Launch LedGrab" finish-page action are unchanged. ([de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44))
- **Simpler segment payloads** — `SegmentPayload.start` defaults to 0 and `length` defaults to "the rest of the strip from start". A single segment with only `mode` + `color` now fills the entire strip — no more `length: 9999` magic value clients had to pass. ([1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5))
- About panel now houses the author + contact details that previously lived in a global app footer, freeing up vertical space across every page (en/ru/zh `donation.about_author` key added). ([816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d))
### Performance
- **LED hot path is allocation-free per-frame**: Adalight gets a dedicated single-worker tx executor, pre-allocated wire buffer, uint8 scratch, and a precomputed header struct; DDP gets a pre-built `struct.Struct` and memoryview emit path; calibration precomputes Phase 3 skip-LED resampling so per-frame work is now `np.take` + in-place blend; the WLED target processor gets a matching tightening. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- Per-surface card presentation modes (C/M/D/R) for the UI ([75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487))
- Customisable card icon for all entity types ([0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e))
- HA-Light: broadcast a single Color Value Source to all entities ([a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf))
- Targets: customisable card icon plus HA-light stop action ([ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc))
- Customisable card icon plate for devices ([49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb))
### Bug Fixes
- **Audio-source modal preserves device on refresh** — refresh button moved into the label row (no more overflow past the Source panel edge); selection is restored by matching on `(index, loopback)` first with a trimmed-name fallback for OS-side reindexing; the EntitySelect trigger now syncs so the visible label matches the underlying `<select>` in edit mode. ([0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4))
- **PWA meta tag** — add the standard `mobile-web-app-capable` tag while keeping the Apple variant for iOS Safari, since Chrome deprecated `apple-mobile-web-app-capable`. ([8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3))
- Shutdown: apply target stop actions before tearing down HA/MQTT so devices end up in their configured state ([6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b))
---
### Development / Internal
#### CI/Build
- Add `workflow_dispatch` and skip lint/test on release commits (release.yml already runs in parallel; manual dispatch covers re-runs on demand). ([033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6))
#### Tests
- New `test_camera_engine` suite covers the new capture path. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- Adalight + DDP tests cover header format, buffer reuse, non-contiguous input, brightness scaling, RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- 13 new tests for the device-event notifications backend (full suite still 899 passing). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- `conftest` pre-creates the test DB so `main.py`'s legacy-data migration no longer shovels the user's production DB into the test temp dir; `test_preferences_notifications` wipes its own setting at the start of the defaults test (was relying on isolation it never enforced). ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
- Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
#### Tooling
- `.mcp.json` checked in with code-review-graph MCP server config so the graph tools are available out of the box. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
#### Chores
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
---
@@ -45,19 +31,13 @@ This release adds **device-event notifications** (snack + Web Notifications), a
| Hash | Message | Author |
|------|---------|--------|
| [0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4) | fix(ui): audio-source modal — preserve device on refresh, relocate refresh action | alexei.dolgolyov |
| [fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b) | feat: daylight tz, camera engine, value stream + modal/UI polish | alexei.dolgolyov |
| [816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d) | refactor(ui): drop app footer, move author info to About panel | alexei.dolgolyov |
| [797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806) | feat: LED hot-path perf, tutorials expansion, modal markup polish | alexei.dolgolyov |
| [9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534) | feat(ui): release notes overlay v2 + settings/streams/dashboard polish | alexei.dolgolyov |
| [51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2) | feat(ui): redesign target pipeline as compact strip + chip row | alexei.dolgolyov |
| [9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2) | feat(ui): align Targets metric cells with dashboard pattern | alexei.dolgolyov |
| [233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463) | feat(ui): migrate Targets cards to mod-card system | alexei.dolgolyov |
| [de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44) | feat(autostart): suppress browser auto-open on Windows login | alexei.dolgolyov |
| [1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5) | feat(api-input): make SegmentPayload start/length optional | alexei.dolgolyov |
| [a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b) | feat(ui): cards redesign + settings, modal, toolbar polish | alexei.dolgolyov |
| [8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32) | feat(notifications): device event notifications (snack + Web Notifications) | alexei.dolgolyov |
| [8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3) | fix(pwa): add mobile-web-app-capable meta tag | alexei.dolgolyov |
| [033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6) | ci: add workflow_dispatch and skip lint/test on release commits | alexei.dolgolyov |
| [75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487) | feat(ui): per-surface card presentation modes (C/M/D/R) | alexei.dolgolyov |
| [e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4) | chore: clean up cfg abbreviation and stale TODO link | alexei.dolgolyov |
| [6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b) | fix(shutdown): apply target stop actions before tearing down HA/MQTT | alexei.dolgolyov |
| [0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e) | feat(ui): customisable card icon for all entity types | alexei.dolgolyov |
| [a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf) | feat(ha-light): broadcast a single Color Value Source to all entities | alexei.dolgolyov |
| [ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc) | feat(targets): customisable card icon + HA-light stop action | alexei.dolgolyov |
| [49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb) | feat(ui): customisable card icon plate for devices | alexei.dolgolyov |
| [a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b) | ci(android): fail-fast on missing release keystore before SDK setup | alexei.dolgolyov |
</details>
+91 -1
View File
@@ -1,5 +1,95 @@
# LedGrab TODO
## Custom card icons — extend to all card types
Migrate the existing icon-plate work (devices, LED targets, HA-light targets)
to all remaining card types. ~17 entity types. Branch: `feat/icons-everywhere`.
### Foundation
- [x] Refactor `icon-picker.ts` — replace hardcoded 2-entry `_adapters`
record with a `Map<EntityType, EntityTypeAdapter>` and expose
`registerIconEntityType()` for feature modules to register their
own. Added `makeSimpleIconAdapter()` helper that reduces a
registration to ~6 lines.
- [x] Generalised `bodyExtras` for discriminated routes (output-targets
`target_type` etc.) — now keyed off id, adapter does its own
lookup.
- [x] `_onDocumentClick` accepts any registered type instead of
hardcoded device/target check.
- [x] Locale entity-type labels added to en/ru/zh for 18 new types
(picture_source, audio_source, weather_source, value_source,
mqtt_source, ha_source, automation, scene_preset, sync_clock,
game_integration, audio_processing_template, pattern_template,
capture_template, pp_template, cspt, audio_template, gradient,
color_strip_source, asset).
### Backend (storage + schemas + routes per entity)
Recipe: add `icon: str = ""` + `icon_color: str = ""` to dataclass,
emit-when-truthy in `to_dict`, default `""` in `from_dict`; add 3
`Optional[str]` Field defs to Create/Response/Update schemas; thread
`getattr(entity, "icon", "") or ""` into the response builder.
SQLite JSON-blob storage means **no migration required**.
- [x] Integrations (6): weather_sources, value_sources, mqtt_source,
home_assistant_source, sync_clocks, game_integration
- [x] Streams (10): picture_source, audio_source, audio_template,
audio_processing_template, pattern_template, postprocessing_template,
color_strip_processing_template, color_strip_source, gradient,
capture_template (`storage/template.py` — was missed by initial pass)
- [x] Other (3): automation, scene_preset, asset
### Frontend (per feature module)
For each card render call:
- Use the new `core/card-icon.ts` helper:
`...makeCardIconFields('<type>', entity.id, entity)` spread into the
mod-card head — computes `iconHtml`/`iconColor`/`iconAttrs` in one go.
- Register the entity type in the feature module via
`registerIconEntityType('<type>', makeSimpleIconAdapter({ … }))`.
Modules wired:
- [x] streams.ts (7 cards: picture, capture, pp, cspt, audio source,
audio template, gradient — built-in gradients skip the plate)
- [x] automations.ts
- [x] scene-presets.ts
- [x] sync-clocks.ts
- [x] weather-sources.ts
- [x] value-sources.ts (bodyExtras propagates `source_type`)
- [x] mqtt-sources.ts
- [x] home-assistant-sources.ts
- [x] game-integration.ts
- [x] audio-processing-templates.ts
- [x] assets.ts
- [x] color-strips/cards.ts (bodyExtras propagates `source_type`)
- [WONTDO] pattern-templates.ts — uses legacy `wrapCard({content, actions})`
string API, not the mod-card system. Migration would be a separate
effort and the cards are tiny (name + rect count) so the value is low.
### Discriminated routes
Adapters provide `bodyExtras` to inject the discriminator field on PUT
so the Pydantic discriminated-union route validators don't reject the
icon-only update:
- output-targets → `target_type` (already wired before)
- color-strip-sources → `source_type`
- audio-sources → `source_type`
- value-sources → `source_type`
- picture-sources → `stream_type`
### Verification
- [x] `cd server && ruff check src/ tests/` clean
- [x] `cd server && npx tsc --noEmit` clean
- [x] `cd server && npm run build` produces 2.6 MB bundle
- [x] `cd server && py -3.13 -m pytest tests/ --no-cov -q` — 949 passed
- [ ] Manual: open picker on each card type, confirm save persists,
confirm channel-color preview matches the live card
## Device Event Notifications
Notify the user when LED devices come online/go offline (configured targets), and when new
@@ -434,7 +524,7 @@ Beyond the `/proc`-based AndroidMetricsProvider that's now in place:
## Refactor: Per-Provider Device Configs
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md).
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses.
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
+1 -1
View File
@@ -40,7 +40,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.6.0"
versionName = "0.6.1"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.6.0"
version = "0.6.1"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+2
View File
@@ -142,6 +142,8 @@ async def update_asset(
name=body.name,
description=body.description,
tags=body.tags,
icon=body.icon,
icon_color=body.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
@@ -36,6 +36,8 @@ def _apt_to_response(t) -> AudioProcessingTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -73,6 +75,8 @@ async def create_audio_processing_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_processing_template", "created", template.id)
return _apt_to_response(template)
@@ -129,6 +133,8 @@ async def update_audio_processing_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_processing_template", "updated", template_id)
# Hot-update: rebuild filter pipelines for running streams using this template
@@ -46,6 +46,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
device_index=s.device_index,
is_loopback=s.is_loopback,
audio_template_id=s.audio_template_id,
@@ -57,6 +59,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
audio_source_id=s.audio_source_id,
audio_processing_template_id=s.audio_processing_template_id,
),
@@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
device_index=getattr(source, "device_index", -1),
is_loopback=getattr(source, "is_loopback", True),
audio_template_id=getattr(source, "audio_template_id", None),
@@ -53,6 +53,8 @@ async def list_audio_templates(
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
for t in templates
]
@@ -81,6 +83,8 @@ async def create_audio_template(
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse(
@@ -92,6 +96,8 @@ async def create_audio_template(
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
icon=getattr(template, "icon", "") or "",
icon_color=getattr(template, "icon_color", "") or "",
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -127,6 +133,8 @@ async def get_audio_template(
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -150,6 +158,8 @@ async def update_audio_template(
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse(
@@ -161,6 +171,8 @@ async def update_audio_template(
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -122,6 +122,8 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags,
icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at,
updated_at=automation.updated_at,
)
@@ -191,6 +193,8 @@ async def create_automation(
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
if automation.enabled:
@@ -285,6 +289,8 @@ async def update_automation(
rules=rules,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
if data.scene_preset_id is not None:
update_kwargs["scene_preset_id"] = data.scene_preset_id
@@ -43,6 +43,8 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -84,6 +86,8 @@ async def create_cspt(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template)
@@ -141,6 +145,8 @@ async def update_cspt(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template)
@@ -65,6 +65,8 @@ def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
)
+4
View File
@@ -71,6 +71,8 @@ def _device_to_response(device) -> DeviceResponse:
default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids,
group_mode=device.group_mode,
icon=getattr(device, "icon", "") or "",
icon_color=getattr(device, "icon_color", "") or "",
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -439,6 +441,8 @@ async def update_device(
ble_govee_key=update_data.ble_govee_key,
group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode,
icon=update_data.icon,
icon_color=update_data.icon_color,
)
# Sync connection info in processor manager
@@ -158,6 +158,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
updated_at=config.updated_at,
description=config.description,
tags=config.tags,
icon=getattr(config, "icon", "") or "",
icon_color=getattr(config, "icon_color", "") or "",
)
@@ -255,6 +257,8 @@ async def create_integration(
event_mappings=mappings,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("game_integration", "created", config.id)
@@ -323,6 +327,8 @@ async def update_integration(
event_mappings=mappings,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("game_integration", "updated", integration_id)
@@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse:
tags=gradient.tags,
created_at=gradient.created_at,
updated_at=gradient.updated_at,
icon=getattr(gradient, "icon", "") or "",
icon_color=getattr(gradient, "icon_color", "") or "",
)
@@ -66,6 +68,8 @@ async def create_gradient(
stops=[s.model_dump() for s in data.stops],
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("gradient", "created", gradient.id)
return _to_response(gradient)
@@ -103,6 +107,8 @@ async def update_gradient(
stops=stops,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("gradient", "updated", gradient_id)
return _to_response(gradient)
@@ -55,6 +55,8 @@ def _to_response(
entity_count=len(runtime.get_all_states()) if runtime else 0,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
token=token_field,
@@ -105,6 +107,8 @@ async def create_ha_source(
entity_filters=data.entity_filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -158,6 +162,8 @@ async def update_ha_source(
entity_filters=data.entity_filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
+6
View File
@@ -45,6 +45,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
connected=runtime.is_connected if runtime else False,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
)
@@ -90,6 +92,8 @@ async def create_mqtt_source(
base_topic=data.base_topic,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -139,6 +143,8 @@ async def update_mqtt_source(
base_topic=data.base_topic,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
@@ -11,6 +11,7 @@ from ledgrab.api.dependencies import (
get_device_store,
get_output_target_store,
get_processor_manager,
get_value_source_store,
)
from ledgrab.api.schemas.output_targets import (
HALightMappingSchema,
@@ -30,6 +31,7 @@ from ledgrab.storage.ha_light_output_target import (
HALightOutputTarget,
)
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
@@ -54,6 +56,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,
)
@@ -66,8 +70,11 @@ def _ha_light_target_to_response(
return HALightOutputTargetResponse(
id=target.id,
name=target.name,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
ha_source_id=target.ha_source_id or "",
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
# Defensive coalesce — older records stored via resolve_ref may hold None.
color_strip_source_id=target.color_strip_source_id or "",
color_value_source_id=target.color_value_source_id or "",
brightness=target.brightness.to_dict(),
ha_light_mappings=[
HALightMappingSchema(
@@ -82,13 +89,42 @@ 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,
)
def _validate_color_value_source(
value_source_store: ValueSourceStore, color_value_source_id: str
) -> None:
"""Ensure the referenced ValueSource exists and returns colour."""
if not color_value_source_id:
raise HTTPException(
status_code=400,
detail="color_value_source_id is required when source_kind='color_vs'",
)
try:
source = value_source_store.get_source(color_value_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(
status_code=422,
detail=f"Color value source {color_value_source_id} not found",
)
if source.to_dict().get("return_type") != "color":
raise HTTPException(
status_code=400,
detail=(
f"Value source {color_value_source_id} does not return colour "
"(return_type must be 'color')"
),
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget):
@@ -119,6 +155,7 @@ async def create_target(
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
):
"""Create a new output target."""
try:
@@ -130,6 +167,15 @@ async def create_target(
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Validate color VS reference for HA-light targets in color_vs mode
if (
getattr(data, "target_type", "") == "ha_light"
and getattr(data, "source_kind", "css") == "color_vs"
):
_validate_color_value_source(
value_source_store, getattr(data, "color_value_source_id", "")
)
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = (
[
@@ -161,10 +207,13 @@ async def create_target(
description=data.description,
tags=data.tags,
ha_source_id=getattr(data, "ha_source_id", ""),
source_kind=getattr(data, "source_kind", "css"),
color_value_source_id=getattr(data, "color_value_source_id", ""),
ha_light_mappings=ha_mappings,
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
@@ -243,6 +292,7 @@ async def update_target(
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
):
"""Update a output target."""
try:
@@ -254,6 +304,21 @@ async def update_target(
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Validate color VS reference for HA-light targets switching into / staying in color_vs
if getattr(data, "target_type", "") == "ha_light":
new_kind = getattr(data, "source_kind", None)
new_color_vs = getattr(data, "color_value_source_id", None)
if new_kind == "color_vs" or (new_kind is None and new_color_vs):
# Determine effective id: payload id if provided, else existing target's id
effective_id = new_color_vs
if effective_id is None:
try:
existing = target_store.get_target(target_id)
effective_id = getattr(existing, "color_value_source_id", "")
except ValueError:
effective_id = ""
_validate_color_value_source(value_source_store, effective_id or "")
# Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None
@@ -283,11 +348,16 @@ 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),
source_kind=getattr(data, "source_kind", None),
color_value_source_id=getattr(data, "color_value_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 +371,9 @@ 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)
source_kind = getattr(data, "source_kind", None)
color_value_source_id = getattr(data, "color_value_source_id", None)
try:
await asyncio.to_thread(
@@ -317,6 +390,9 @@ 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
or source_kind is not None
or color_value_source_id is not None
),
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
@@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -83,6 +85,8 @@ async def create_pattern_template(
rectangles=rectangles,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template)
@@ -139,6 +143,8 @@ async def update_pattern_template(
rectangles=rectangles,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template)
@@ -65,6 +65,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
display_index=s.display_index,
capture_template_id=s.capture_template_id,
target_fps=s.target_fps,
@@ -76,6 +78,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
source_stream_id=s.source_stream_id,
postprocessing_template_id=s.postprocessing_template_id,
),
@@ -86,6 +90,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
image_asset_id=s.image_asset_id,
),
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
@@ -95,6 +101,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
video_asset_id=s.video_asset_id,
loop=s.loop,
playback_speed=s.playback_speed,
@@ -49,6 +49,8 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -86,6 +88,8 @@ async def create_pp_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template)
@@ -143,6 +147,8 @@ async def update_pp_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template)
@@ -37,6 +37,7 @@ router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
_CARD_MODES_KEY = "card_modes"
class DaylightTimezonePreference(BaseModel):
@@ -163,6 +164,90 @@ async def put_notification_preferences(
return body
# ---------------------------------------------------------------------------
# Card presentation modes (per-surface comfortable/compact/dense)
# ---------------------------------------------------------------------------
_VALID_CARD_MODES = {"comfortable", "compact", "dense", "row"}
@router.get(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def get_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, Any]:
"""Read the saved card-mode preferences. Returns an empty object when
nothing has been saved yet — the frontend falls back to the default
mode ("compact") for every surface in that case."""
value = db.get_setting(_CARD_MODES_KEY)
return value if value is not None else {}
@router.put(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def put_card_modes(
_: AuthRequired,
body: dict[str, Any] = Body(...),
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Save card-mode preferences. The body must be a JSON object shaped
like ``{"version": 1, "surfaces": {"<surface>": "<mode>", …}}``.
The surface registry is intentionally open (any string accepted) so
new card surfaces can adopt the toggle without a server migration.
Invalid mode values are rejected to prevent a bad client from
poisoning the stored value."""
if not isinstance(body, dict):
raise HTTPException(status_code=422, detail="Body must be a JSON object")
if not isinstance(body.get("version"), int):
raise HTTPException(
status_code=422,
detail="Body must include a numeric 'version' field",
)
surfaces = body.get("surfaces", {})
if not isinstance(surfaces, dict):
raise HTTPException(
status_code=422,
detail="'surfaces' must be an object mapping surface keys to modes",
)
for key, mode in surfaces.items():
if not isinstance(key, str) or not key:
raise HTTPException(
status_code=422,
detail=f"Surface keys must be non-empty strings (got {key!r})",
)
if mode not in _VALID_CARD_MODES:
raise HTTPException(
status_code=422,
detail=(
f"Surface {key!r} has invalid mode {mode!r}; "
f"expected one of {sorted(_VALID_CARD_MODES)}"
),
)
db.set_setting(_CARD_MODES_KEY, body)
return {"ok": True}
@router.delete(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def delete_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Delete saved card-mode preferences — every surface reverts to the
frontend default on next load."""
db.set_setting(_CARD_MODES_KEY, {})
return {"ok": True}
# ---------------------------------------------------------------------------
# Daylight timezone (global)
# ---------------------------------------------------------------------------
@@ -51,6 +51,8 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
],
order=preset.order,
tags=preset.tags,
icon=getattr(preset, "icon", "") or "",
icon_color=getattr(preset, "icon_color", "") or "",
created_at=preset.created_at,
updated_at=preset.updated_at,
)
@@ -84,6 +86,8 @@ async def create_scene_preset(
targets=targets,
order=store.count(),
tags=data.tags if data.tags is not None else [],
icon=data.icon or "",
icon_color=data.icon_color or "",
created_at=now,
updated_at=now,
)
@@ -182,6 +186,8 @@ async def update_scene_preset(
order=data.order,
targets=new_targets,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except ValueError as e:
raise HTTPException(
@@ -38,6 +38,8 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
speed=rt.speed if rt else clock.speed,
description=clock.description,
tags=clock.tags,
icon=getattr(clock, "icon", "") or "",
icon_color=getattr(clock, "icon_color", "") or "",
is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at,
@@ -75,6 +77,8 @@ async def create_sync_clock(
speed=data.speed,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager)
@@ -120,6 +124,8 @@ async def update_sync_clock(
speed=data.speed,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
# Hot-update runtime speed
if data.speed is not None:
+23 -43
View File
@@ -45,6 +45,21 @@ logger = get_logger(__name__)
router = APIRouter()
def _template_to_response(t) -> TemplateResponse:
return TemplateResponse(
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
# ===== CAPTURE TEMPLATE ENDPOINTS =====
@@ -57,19 +72,7 @@ async def list_templates(
try:
templates = template_store.get_all_templates()
template_responses = [
TemplateResponse(
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
)
for t in templates
]
template_responses = [_template_to_response(t) for t in templates]
return TemplateListResponse(
templates=template_responses,
@@ -100,19 +103,12 @@ async def create_template(
engine_config=template_data.engine_config,
description=template_data.description,
tags=template_data.tags,
icon=template_data.icon,
icon_color=template_data.icon_color,
)
fire_entity_event("capture_template", "created", template.id)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
return _template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -138,16 +134,7 @@ async def get_template(
except ValueError:
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
return _template_to_response(template)
@router.put(
@@ -168,19 +155,12 @@ async def update_template(
engine_config=update_data.engine_config,
description=update_data.description,
tags=update_data.tags,
icon=update_data.icon,
icon_color=update_data.icon_color,
)
fire_entity_event("capture_template", "updated", template_id)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
return _template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -64,6 +64,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
value=s.value,
@@ -73,6 +75,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
waveform=s.waveform,
@@ -85,6 +89,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
@@ -100,6 +106,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
speed=s.speed,
@@ -114,6 +122,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
color=list(s.color),
@@ -123,6 +133,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
colors=[list(c) for c in s.colors],
@@ -135,6 +147,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
schedule=s.schedule,
@@ -144,6 +158,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
ha_source_id=s.ha_source_id,
@@ -158,6 +174,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
value_source_id=s.value_source_id,
@@ -169,6 +187,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
color_strip_source_id=s.color_strip_source_id,
@@ -180,6 +200,8 @@ _RESPONSE_MAP = {
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
metric=s.metric,
@@ -204,6 +226,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
picture_source_id=source.picture_source_id,
@@ -218,6 +242,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
schedule=source.schedule,
@@ -233,6 +259,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
value=getattr(source, "value", 1.0),
@@ -39,6 +39,8 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse:
update_interval=d["update_interval"],
description=d.get("description"),
tags=d.get("tags", []),
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
)
@@ -79,6 +81,8 @@ async def create_weather_source(
update_interval=data.update_interval,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -125,6 +129,8 @@ async def update_weather_source(
update_interval=data.update_interval,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
+20
View File
@@ -12,6 +12,16 @@ class AssetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AssetResponse(BaseModel):
@@ -26,6 +36,16 @@ class AssetResponse(BaseModel):
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -17,6 +17,16 @@ class AudioProcessingTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class AudioProcessingTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateResponse(BaseModel):
@@ -42,6 +62,16 @@ class AudioProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _AudioSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceResponse(_AudioSourceResponseBase):
@@ -53,6 +63,16 @@ class _AudioSourceCreateBase(BaseModel):
name: str = Field(description="Source 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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceCreate(_AudioSourceCreateBase):
@@ -87,6 +107,16 @@ class _AudioSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source 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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
@@ -16,6 +16,16 @@ class AudioTemplateCreate(BaseModel):
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateUpdate(BaseModel):
@@ -26,6 +36,16 @@ class AudioTemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateResponse(BaseModel):
@@ -39,6 +59,16 @@ class AudioTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateListResponse(BaseModel):
@@ -67,6 +67,16 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AutomationUpdate(BaseModel):
@@ -84,6 +94,16 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AutomationResponse(BaseModel):
@@ -108,6 +128,16 @@ class AutomationResponse(BaseModel):
last_deactivated_at: Optional[datetime] = Field(
None, description="Last time this automation was deactivated"
)
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -17,6 +17,16 @@ class ColorStripProcessingTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class ColorStripProcessingTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class ColorStripProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateListResponse(BaseModel):
@@ -95,6 +95,16 @@ class _CSSResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSResponse(_CSSResponseBase):
@@ -266,6 +276,16 @@ class _CSSCreateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSCreate(_CSSCreateBase):
@@ -450,6 +470,16 @@ class _CSSUpdateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSUpdate(_CSSUpdateBase):
+24
View File
@@ -86,6 +86,17 @@ class DeviceCreate(BaseModel):
None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
)
# Custom card icon (frontend display only)
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
)
class DeviceUpdate(BaseModel):
@@ -140,6 +151,17 @@ class DeviceUpdate(BaseModel):
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
# Custom card icon
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class CalibrationLineSchema(BaseModel):
@@ -295,6 +317,8 @@ class DeviceResponse(BaseModel):
default_factory=list, description="Ordered list of child device IDs (for group device type)"
)
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
icon: str = Field(default="", description="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")
@@ -42,6 +42,16 @@ class GameIntegrationCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class GameIntegrationUpdate(BaseModel):
@@ -56,6 +66,16 @@ class GameIntegrationUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class GameIntegrationResponse(BaseModel):
@@ -71,6 +91,16 @@ class GameIntegrationResponse(BaseModel):
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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 GameIntegrationListResponse(BaseModel):
@@ -20,6 +20,16 @@ class GradientCreate(BaseModel):
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientUpdate(BaseModel):
@@ -29,6 +39,16 @@ class GradientUpdate(BaseModel):
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientResponse(BaseModel):
@@ -42,6 +62,16 @@ class GradientResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientListResponse(BaseModel):
@@ -18,6 +18,16 @@ class HomeAssistantSourceCreate(BaseModel):
)
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class HomeAssistantSourceUpdate(BaseModel):
@@ -30,6 +40,16 @@ class HomeAssistantSourceUpdate(BaseModel):
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class HomeAssistantSourceResponse(BaseModel):
@@ -44,6 +64,16 @@ class HomeAssistantSourceResponse(BaseModel):
entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
token: Optional[str] = Field(
+30
View File
@@ -18,6 +18,16 @@ class MQTTSourceCreate(BaseModel):
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class MQTTSourceUpdate(BaseModel):
@@ -32,6 +42,16 @@ class MQTTSourceUpdate(BaseModel):
base_topic: Optional[str] = Field(None, description="Base topic prefix")
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class MQTTSourceResponse(BaseModel):
@@ -48,6 +68,16 @@ class MQTTSourceResponse(BaseModel):
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -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")
@@ -81,7 +83,19 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
class HALightOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
source_kind: Literal["css", "color_vs"] = Field(
default="css",
description="Colour source kind: 'css' (per-mapping LED segments) or "
"'color_vs' (single colour value source applied to all entities).",
)
color_strip_source_id: str = Field(
default="", description="Color strip source ID (used when source_kind='css')"
)
color_value_source_id: str = Field(
default="",
description="Colour value source ID (used when source_kind='color_vs'); "
"must reference a value source whose return_type='color'.",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
@@ -98,6 +112,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 +138,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):
@@ -160,7 +185,18 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
class HALightOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
source_kind: Literal["css", "color_vs"] = Field(
default="css",
description="Colour source kind: 'css' (per-mapping LED segments) or "
"'color_vs' (single colour value source applied to all entities).",
)
color_strip_source_id: str = Field(
default="", description="Color strip source ID (used when source_kind='css')"
)
color_value_source_id: str = Field(
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
@@ -180,6 +216,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 +241,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):
@@ -229,7 +279,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
source_kind: Optional[Literal["css", "color_vs"]] = Field(
None,
description="Colour source kind: 'css' or 'color_vs'.",
)
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field(
None,
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
@@ -246,6 +304,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[
@@ -17,6 +17,16 @@ class PatternTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PatternTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PatternTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _PictureSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceResponse(_PictureSourceResponseBase):
@@ -72,6 +82,16 @@ class _PictureSourceCreateBase(BaseModel):
name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceCreate(_PictureSourceCreateBase):
@@ -127,6 +147,16 @@ class _PictureSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
@@ -17,6 +17,16 @@ class PostprocessingTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PostprocessingTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PostprocessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateListResponse(BaseModel):
@@ -23,6 +23,16 @@ class ScenePresetCreate(BaseModel):
None, description="Target IDs to capture (all if omitted)"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ScenePresetUpdate(BaseModel):
@@ -36,6 +46,16 @@ class ScenePresetUpdate(BaseModel):
description="Update target list: keep state for existing, capture fresh for new, drop removed",
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ScenePresetResponse(BaseModel):
@@ -47,6 +67,16 @@ class ScenePresetResponse(BaseModel):
targets: List[TargetSnapshotSchema]
order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime
updated_at: datetime
@@ -13,6 +13,16 @@ class SyncClockCreate(BaseModel):
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class SyncClockUpdate(BaseModel):
@@ -22,6 +32,16 @@ class SyncClockUpdate(BaseModel):
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class SyncClockResponse(BaseModel):
@@ -32,6 +52,16 @@ class SyncClockResponse(BaseModel):
speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
is_running: bool = Field(True, description="Whether clock is currently running")
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
created_at: datetime = Field(description="Creation timestamp")
@@ -14,6 +14,16 @@ class TemplateCreate(BaseModel):
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class TemplateUpdate(BaseModel):
@@ -24,6 +34,16 @@ class TemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class TemplateResponse(BaseModel):
@@ -37,6 +57,12 @@ class TemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None, max_length=64, description="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 TemplateListResponse(BaseModel):
@@ -17,6 +17,16 @@ class _ValueSourceResponseBase(BaseModel):
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -171,6 +181,16 @@ class _ValueSourceCreateBase(BaseModel):
name: str = Field(description="Source 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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class StaticValueSourceCreate(_ValueSourceCreateBase):
@@ -320,6 +340,16 @@ class _ValueSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source 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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
@@ -25,6 +25,16 @@ class WeatherSourceCreate(BaseModel):
)
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class WeatherSourceUpdate(BaseModel):
@@ -44,6 +54,16 @@ class WeatherSourceUpdate(BaseModel):
)
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="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class WeatherSourceResponse(BaseModel):
@@ -60,6 +80,16 @@ class WeatherSourceResponse(BaseModel):
update_interval: int = Field(description="API poll interval in seconds")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
+3 -3
View File
@@ -100,7 +100,7 @@ class MQTTConfig(BaseSettings):
base_topic: str = "ledgrab"
def resolve_mqtt_password(cfg: "Config | None" = None) -> str:
def resolve_mqtt_password(config: "Config | None" = None) -> str:
"""Return the plaintext MQTT password.
Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If
@@ -110,8 +110,8 @@ def resolve_mqtt_password(cfg: "Config | None" = None) -> str:
from ledgrab.utils import get_logger, secret_box
log = get_logger(__name__)
cfg = cfg or get_config()
pw = cfg.mqtt.password or ""
config = config or get_config()
pw = config.mqtt.password or ""
if not pw:
return ""
if secret_box.is_encrypted(pw):
@@ -26,7 +26,9 @@ class HALightTargetProcessor(TargetProcessor):
self,
target_id: str,
ha_source_id: str,
source_kind: str = "css",
color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness=None,
# legacy compat
brightness_value_source_id: str = "",
@@ -35,13 +37,16 @@ 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
super().__init__(target_id, ctx)
self._ha_source_id = ha_source_id
self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css"
self._css_id = color_strip_source_id
self._color_vs_id = color_value_source_id
# Accept BindableFloat or legacy string
if brightness is not None and isinstance(brightness, BindableFloat):
self._brightness = brightness
@@ -56,14 +61,20 @@ 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
self._color_stream = None # color-returning ValueStream (source_kind="color_vs")
self._ha_runtime = None
self._value_stream = None # brightness value source stream
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
@@ -75,14 +86,23 @@ class HALightTargetProcessor(TargetProcessor):
if self._is_running:
return
# Acquire CSS stream
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}")
# Acquire colour source — CSS stream OR colour value stream depending on mode.
if self._source_kind == "color_vs":
if self._color_vs_id and self._ctx.value_stream_manager:
try:
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
except Exception as e:
logger.warning(
f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
)
else:
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}")
# Acquire HA runtime
try:
@@ -104,6 +124,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 +143,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:
@@ -127,6 +159,14 @@ class HALightTargetProcessor(TargetProcessor):
pass
self._css_stream = None
# Release colour value stream (color_vs mode)
if self._color_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._color_vs_id)
except Exception:
pass
self._color_stream = None
# Release brightness value stream
if self._value_stream is not None and self._ctx.value_stream_manager:
try:
@@ -148,6 +188,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,13 +218,30 @@ 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
# source_kind / color_value_source_id swap is handled here so that
# toggling modes (or repointing the colour VS) takes effect without
# restarting the target. CSS swaps continue to flow through
# update_css_source().
new_kind = settings.get("source_kind")
new_color_vs = settings.get("color_value_source_id")
kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind
color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id
if kind_changed or color_vs_changed:
self._swap_color_source(
new_kind if kind_changed else self._source_kind,
new_color_vs if new_color_vs is not None else self._color_vs_id,
)
def update_css_source(self, color_strip_source_id: str) -> None:
"""Hot-swap the CSS stream."""
"""Hot-swap the CSS stream (only meaningful when source_kind='css')."""
old_id = self._css_id
self._css_id = color_strip_source_id
if self._is_running and self._ctx.color_strip_stream_manager:
if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager:
try:
new_stream = self._ctx.color_strip_stream_manager.acquire(
color_strip_source_id, self._target_id
@@ -195,6 +253,52 @@ class HALightTargetProcessor(TargetProcessor):
except Exception as e:
logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}")
def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
"""Release the previous colour stream and acquire the new one."""
# Tear down previous stream first to keep ref-counts honest.
if self._is_running:
if self._css_stream and self._ctx.color_strip_stream_manager:
try:
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
except Exception:
pass
self._css_stream = None
if self._color_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._color_vs_id)
except Exception:
pass
self._color_stream = None
self._source_kind = new_kind
self._color_vs_id = new_color_vs_id
# Reset per-entity history so the new source isn't gated by stale values.
self._previous_colors.clear()
self._previous_on.clear()
if not self._is_running:
return
if self._source_kind == "color_vs":
if self._color_vs_id and self._ctx.value_stream_manager:
try:
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
except Exception as e:
logger.warning(
f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
)
else:
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(
f"HA light {self._target_id}: failed to re-acquire CSS stream: {e}"
)
# ── WebSocket clients ──
def add_ws_client(self, ws: Any) -> None:
@@ -217,7 +321,9 @@ class HALightTargetProcessor(TargetProcessor):
"target_id": self._target_id,
"processing": self._is_running,
"ha_source_id": self._ha_source_id,
"source_kind": self._source_kind,
"css_id": self._css_id,
"color_value_source_id": self._color_vs_id,
"is_running": self._is_running,
"ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False,
"light_count": len(self._light_mappings),
@@ -244,17 +350,28 @@ class HALightTargetProcessor(TargetProcessor):
}
async def _processing_loop(self) -> None:
"""Main loop: read CSS colors, average per mapping, send to HA lights."""
"""Main loop: read source colour(s) and send to HA lights."""
interval = 1.0 / self._update_rate
while self._is_running:
try:
loop_start = time.monotonic()
if self._css_stream and self._ha_runtime and self._ha_runtime.is_connected:
colors = self._css_stream.get_latest_colors()
if colors is not None and len(colors) > 0:
await self._update_lights(colors)
ha_ready = self._ha_runtime and self._ha_runtime.is_connected
if ha_ready:
if self._source_kind == "color_vs" and self._color_stream is not None:
try:
color = self._color_stream.get_color()
except Exception:
color = None
if isinstance(color, (list, tuple)) and len(color) >= 3:
await self._update_lights_single_color(
int(color[0]), int(color[1]), int(color[2])
)
elif self._css_stream is not None:
colors = self._css_stream.get_latest_colors()
if colors is not None and len(colors) > 0:
await self._update_lights(colors)
# Sleep for remaining frame time
elapsed = time.monotonic() - loop_start
@@ -267,99 +384,110 @@ class HALightTargetProcessor(TargetProcessor):
logger.error(f"HA light {self._target_id} loop error: {e}")
await asyncio.sleep(1.0)
async def _update_lights(self, colors: np.ndarray) -> None:
"""Average LED segments and call HA services for changed lights."""
led_count = len(colors)
def _read_brightness_multiplier(self) -> float:
if self._value_stream is None:
return 1.0
try:
return float(self._value_stream.get_value())
except Exception:
return 1.0
# Get brightness multiplier from value source (1.0 if not configured)
vs_multiplier = 1.0
if self._value_stream is not None:
try:
vs_multiplier = self._value_stream.get_value()
except Exception:
vs_multiplier = 1.0
async def _send_entity_color(
self, mapping: HALightMapping, r: int, g: int, b: int, vs_multiplier: float
) -> None:
"""Apply tolerance/threshold gates and push one entity update."""
entity_id = mapping.entity_id
# Cache for WS preview (always, even if HA call is skipped)
self._latest_entity_colors[entity_id] = (r, g, b)
# Calculate brightness (0-255) from max channel
brightness = max(r, g, b)
bs = (
mapping.brightness_scale.value
if hasattr(mapping.brightness_scale, "value")
else mapping.brightness_scale
)
eff_scale = bs * vs_multiplier
if eff_scale < 1.0:
brightness = int(brightness * eff_scale)
should_be_on = (
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
)
prev_color = self._previous_colors.get(entity_id)
was_on = self._previous_on.get(entity_id, True)
if should_be_on:
new_color = (r, g, b)
if prev_color is not None and was_on:
dr = abs(r - prev_color[0])
dg = abs(g - prev_color[1])
db = abs(b - prev_color[2])
if max(dr, dg, db) < self._color_tolerance:
return # skip — colour hasn't changed enough
service_data = {
"rgb_color": [r, g, b],
"brightness": min(255, int(brightness * bs)),
}
transition_val = self._transition.value
if transition_val > 0:
service_data["transition"] = transition_val
await self._ha_runtime.call_service(
domain="light",
service="turn_on",
service_data=service_data,
target={"entity_id": entity_id},
)
self._previous_colors[entity_id] = new_color
self._previous_on[entity_id] = True
elif was_on:
await self._ha_runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": entity_id},
)
self._previous_on[entity_id] = False
self._previous_colors.pop(entity_id, None)
async def _update_lights(self, colors: np.ndarray) -> None:
"""CSS mode: average each mapping's LED segment and dispatch."""
led_count = len(colors)
vs_multiplier = self._read_brightness_multiplier()
for mapping in self._light_mappings:
if not mapping.entity_id:
continue
# Resolve LED range
start = max(0, mapping.led_start)
end = mapping.led_end if mapping.led_end >= 0 else led_count
end = min(end, led_count)
if start >= end:
continue
# Average the LED segment
segment = colors[start:end]
avg = segment.mean(axis=0).astype(int)
r, g, b = int(avg[0]), int(avg[1]), int(avg[2])
# Cache for WS preview (always, even if HA call is skipped)
self._latest_entity_colors[mapping.entity_id] = (r, g, b)
# Calculate brightness (0-255) from max channel
brightness = max(r, g, b)
# Apply brightness scale and value source multiplier
bs = (
mapping.brightness_scale.value
if hasattr(mapping.brightness_scale, "value")
else mapping.brightness_scale
)
eff_scale = bs * vs_multiplier
if eff_scale < 1.0:
brightness = int(brightness * eff_scale)
# Check brightness threshold
should_be_on = (
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
await self._send_entity_color(
mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier
)
entity_id = mapping.entity_id
prev_color = self._previous_colors.get(entity_id)
was_on = self._previous_on.get(entity_id, True)
if self._ws_clients and self._latest_entity_colors:
await self._broadcast_entity_colors()
if should_be_on:
# Check if color changed beyond tolerance
new_color = (r, g, b)
if prev_color is not None and was_on:
dr = abs(r - prev_color[0])
dg = abs(g - prev_color[1])
db = abs(b - prev_color[2])
if max(dr, dg, db) < self._color_tolerance:
continue # skip — color hasn't changed enough
async def _update_lights_single_color(self, r: int, g: int, b: int) -> None:
"""color_vs mode: push the same RGB triple to every mapping."""
vs_multiplier = self._read_brightness_multiplier()
# Call light.turn_on
service_data = {
"rgb_color": [r, g, b],
"brightness": min(255, int(brightness * bs)),
}
transition_val = self._transition.value
if transition_val > 0:
service_data["transition"] = transition_val
for mapping in self._light_mappings:
if not mapping.entity_id:
continue
await self._send_entity_color(mapping, r, g, b, vs_multiplier)
await self._ha_runtime.call_service(
domain="light",
service="turn_on",
service_data=service_data,
target={"entity_id": entity_id},
)
self._previous_colors[entity_id] = new_color
self._previous_on[entity_id] = True
elif was_on:
# Brightness dropped below threshold — turn off
await self._ha_runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": entity_id},
)
self._previous_on[entity_id] = False
self._previous_colors.pop(entity_id, None)
# Broadcast colors to WS clients
if self._ws_clients and self._latest_entity_colors:
await self._broadcast_entity_colors()
@@ -378,3 +506,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},
)
@@ -428,7 +428,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self,
target_id: str,
ha_source_id: str,
source_kind: str = "css",
color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness=None,
# legacy compat
brightness_value_source_id: str = "",
@@ -437,6 +439,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:
@@ -447,13 +450,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
proc = HALightTargetProcessor(
target_id=target_id,
ha_source_id=ha_source_id,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness,
light_mappings=light_mappings or [],
update_rate=update_rate,
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
+33 -31
View File
@@ -394,52 +394,34 @@ async def lifespan(app: FastAPI):
# where no CRUD happened during the session.
_save_all_stores()
# Stop Home Assistant manager
try:
await ha_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping Home Assistant manager: {e}")
# Stop weather manager
try:
weather_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping weather manager: {e}")
# Stop update checker
try:
await update_service.stop()
except Exception as e:
logger.error(f"Error stopping update checker: {e}")
# Stop auto-backup engine
try:
await auto_backup_engine.stop()
except Exception as e:
logger.error(f"Error stopping auto-backup engine: {e}")
# Stop automation engine first (deactivates automation-managed scenes)
# Stop automation engine first so it can no longer activate scenes that
# would talk to processors mid-shutdown.
try:
await automation_engine.stop()
logger.info("Stopped automation engine")
except Exception as e:
logger.error(f"Error stopping automation engine: {e}")
# Stop discovery watcher (before health monitor stop so events still flow)
# Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None:
try:
await discovery_watcher.stop()
except Exception as e:
logger.error(f"Error stopping discovery watcher: {e}")
# Stop OS notification listener
try:
os_notif_listener.stop()
except Exception as e:
logger.error(f"Error stopping OS notification listener: {e}")
# Stop all processing.
# The shutdown action setting controls whether per-device restore
# Stop all processing BEFORE tearing down ha_manager / mqtt_manager /
# mqtt_service. HA-light targets need a live HA runtime to apply their
# stop_action (turn_off / restore), and MQTT-output devices need a live
# MQTT broker connection to send restore frames. Shutting those down
# first silently turns "stop_targets" into a no-op for those targets.
#
# The shutdown_action setting controls whether per-device restore
# frames are sent: "stop_targets" (default) runs the normal stop
# sequence; "nothing" cancels capture tasks so the LEDs freeze on
# their last frame.
@@ -458,18 +440,38 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Error stopping processors: {e}")
# Stop MQTT manager (entity-based broker connections)
# Now safe to tear down the connections that processors depended on.
try:
await ha_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping Home Assistant manager: {e}")
try:
await mqtt_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping MQTT manager: {e}")
# Stop MQTT service (legacy global connection)
try:
await mqtt_service.stop()
except Exception as e:
logger.error(f"Error stopping MQTT service: {e}")
# Independent services — order doesn't matter relative to processors.
try:
weather_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping weather manager: {e}")
try:
await update_service.stop()
except Exception as e:
logger.error(f"Error stopping update checker: {e}")
try:
await auto_backup_engine.stop()
except Exception as e:
logger.error(f"Error stopping auto-backup engine: {e}")
# Create FastAPI application
app = FastAPI(
+1
View File
@@ -10,6 +10,7 @@
@import './advanced-calibration.css';
@import './dashboard.css';
@import './dashboard-customize.css';
@import './card-modes.css';
@import './streams.css';
@import './patterns.css';
@import './automations.css';
+22
View File
@@ -43,6 +43,28 @@
--space-lg: 20px;
--space-xl: 40px;
/* Card grid sizing
Tokens for the auto-fill card grids (devices, displays, dashboard
targets, integrations, autostart). Defaults reproduce the values
that were inline before tokenization, so this layer is a no-op
until the card-mode toggle wires `[data-card-mode=]` overrides.
· `*-min` minmax() column width for the main module
cards (devices, displays, dashboard targets/scenes).
· `*-min-narrow` column width for slimmer dashboard-module
rows (integrations, autostart).
· `*-gap` / `*-gap-narrow` corresponding row/column gap. */
--card-grid-min: 380px;
--card-grid-gap: 14px;
--card-grid-min-narrow: 320px;
--card-grid-gap-narrow: 12px;
/* Capture-template / source-card grids (sources, streams, templates,
color strips) have their own column proportions so they stay
distinct from device/target cards. */
--templates-grid-min: 350px;
--templates-grid-gap: 20px;
/* Border radius */
--radius: 8px;
--radius-sm: 4px;
@@ -0,0 +1,223 @@
/*
* Card presentation modes `[data-card-mode="comfortable|compact|dense"]`
*
* Sister to the dashboard `[data-density]` system (which governs section
* header/gap only). This file targets the **card grids and the cards
* themselves**: column min-width, gap, internal padding, and which
* mod-* blocks render visibly.
*
* Apply the attribute to the grid container OR any ancestor (the page
* tab, the section). All tokens cascade.
*
* <section data-card-mode="dense">
* <div class="devices-grid"></div>
* </section>
*
* <div class="dashboard-section" data-density="dense" data-card-mode="dense">
*
* </div>
*
* Defaults (= `compact`) live in base.css :root; this file only overrides
* for `comfortable` and `dense`. Default mode is implicit; the attribute
* may be omitted on grids that haven't migrated yet.
* */
/* ── Comfortable: roomier columns, expanded card padding ─────────── */
[data-card-mode="comfortable"] {
--card-grid-min: 440px;
--card-grid-gap: 18px;
--card-grid-min-narrow: 360px;
--card-grid-gap-narrow: 16px;
--templates-grid-min: 400px;
--templates-grid-gap: 22px;
}
[data-card-mode="comfortable"] .card,
[data-card-mode="comfortable"] .template-card {
padding: 22px 24px 20px;
}
[data-card-mode="comfortable"] .dashboard-target:has(.mod-head) {
padding: 20px 22px 18px 26px;
gap: 16px;
}
[data-card-mode="comfortable"] .dashboard-autostart:has(.mod-head),
[data-card-mode="comfortable"] .dashboard-integration:has(.mod-head) {
padding: 18px 20px 16px;
}
/* ── Dense: tight columns, slim padding, hide auxiliary mod-* blocks ── */
[data-card-mode="dense"] {
--card-grid-min: 260px;
--card-grid-gap: 8px;
--card-grid-min-narrow: 220px;
--card-grid-gap-narrow: 6px;
--templates-grid-min: 240px;
--templates-grid-gap: 10px;
}
[data-card-mode="dense"] .card,
[data-card-mode="dense"] .template-card {
padding: 12px 14px 10px;
}
[data-card-mode="dense"] .dashboard-target:has(.mod-head) {
padding: 10px 14px 10px 18px;
gap: 8px;
}
[data-card-mode="dense"] .dashboard-autostart:has(.mod-head),
[data-card-mode="dense"] .dashboard-integration:has(.mod-head) {
padding: 10px 12px 8px;
gap: 6px;
}
/* Auxiliary content drops out in dense keeps identity (icon, name,
badge, dot) and primary control surfaces, sheds preview + secondary
text. The actual data-bearing metric row is preserved. */
[data-card-mode="dense"] .mod-leds {
display: none;
}
[data-card-mode="dense"] .mod-head {
gap: 6px;
margin-bottom: 6px;
}
[data-card-mode="dense"] .mod-foot {
padding-top: 6px;
gap: 4px;
}
/* Secondary text-button labels collapse to icon-only in dense; icon-only
buttons and the kebab menu are unaffected. Primary action keeps its
label so the "what does this card do" affordance survives. */
[data-card-mode="dense"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label {
display: none;
}
[data-card-mode="dense"] .mod-metrics {
gap: 4px 8px;
}
[data-card-mode="dense"] .mod-metric .k {
font-size: 0.62rem;
}
[data-card-mode="dense"] .mod-metric .v {
font-size: 0.92rem;
}
/* Dashed corner bracket and channel stripe stay they are the card's
identity even at small sizes. No display:none on ::before / ::after. */
/* Row: single column, full-width stacked list
* Differs from `dense` in layout, not just padding: the grid collapses
* to one column so every card spans the available width. Cards keep
* their column-flex internals (mod-head metrics foot) a true
* horizontal row layout would require rewriting the mod-card vocabulary
* and is a separate future mode.
* */
[data-card-mode="row"] {
--card-grid-min: 100%;
--card-grid-gap: 6px;
--card-grid-min-narrow: 100%;
--card-grid-gap-narrow: 6px;
--templates-grid-min: 100%;
--templates-grid-gap: 6px;
}
[data-card-mode="row"] .card,
[data-card-mode="row"] .template-card {
padding: 10px 14px 10px;
}
[data-card-mode="row"] .dashboard-target:has(.mod-head) {
padding: 10px 14px 10px 18px;
gap: 8px;
}
[data-card-mode="row"] .dashboard-autostart:has(.mod-head),
[data-card-mode="row"] .dashboard-integration:has(.mod-head) {
padding: 10px 12px 8px;
gap: 6px;
}
/* Same auxiliary trims as `dense` the row layout is information-dense
by nature, so secondary visuals drop out for the same reasons. */
[data-card-mode="row"] .mod-leds {
display: none;
}
[data-card-mode="row"] .mod-head {
gap: 6px;
margin-bottom: 6px;
}
[data-card-mode="row"] .mod-foot {
padding-top: 6px;
gap: 4px;
}
[data-card-mode="row"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label {
display: none;
}
[data-card-mode="row"] .mod-metrics {
gap: 4px 8px;
}
[data-card-mode="row"] .mod-metric .k {
font-size: 0.62rem;
}
[data-card-mode="row"] .mod-metric .v {
font-size: 0.92rem;
}
/*
* Segmented `C / M / D` toggle sibling of dash-cust-density but
* standalone so any section header / page toolbar can host one without
* pulling in the dashboard-customize stylesheet.
* */
.card-mode-toggle {
display: inline-flex;
gap: 2px;
padding: 2px;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, var(--radius-sm));
background: var(--lux-bg-1, var(--card-bg));
}
.card-mode-toggle__btn {
appearance: none;
border: 0;
background: transparent;
color: var(--lux-ink-dim, var(--text-secondary));
font: 600 0.7rem/1 var(--font-mono, monospace);
letter-spacing: 0.04em;
padding: 4px 8px;
min-width: 22px;
cursor: pointer;
border-radius: calc(var(--lux-r-sm, var(--radius-sm)) - 1px);
transition: background 0.15s ease, color 0.15s ease;
}
.card-mode-toggle__btn:hover {
color: var(--lux-ink, var(--text-color));
background: var(--hover-bg, rgba(255, 255, 255, 0.04));
}
.card-mode-toggle__btn.is-active {
color: var(--primary-contrast, #fff);
background: var(--primary-color);
}
.card-mode-toggle__btn:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
+13 -7
View File
@@ -89,8 +89,8 @@ section {
.displays-grid,
.devices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
gap: var(--card-grid-gap, 14px);
}
.devices-grid > .loading,
@@ -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;
@@ -1116,6 +1116,17 @@ textarea:focus-visible {
max-width: 40%;
}
/* Section header rows in EntityPalette (non-selectable, used for grouping). */
.entity-palette-header {
padding: 6px 14px 2px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
cursor: default;
user-select: none;
}
/* Entity Select trigger (replaces <select>) */
.entity-select-trigger {
display: flex;
@@ -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 {
+141 -4
View File
@@ -71,8 +71,8 @@
.dashboard-subsection .dashboard-section-content {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
gap: var(--card-grid-gap, 14px);
}
.dashboard-subsection .dashboard-section-content .dashboard-target {
@@ -333,6 +333,143 @@
flex-shrink: 0;
}
/* 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.
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;
flex: 0 0 var(--plate-size);
width: var(--plate-size);
height: var(--plate-size);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
background: linear-gradient(180deg,
color-mix(in srgb, var(--ch) 10%, var(--lux-bg-0, var(--bg-color))) 0%,
var(--lux-bg-0, var(--bg-color)) 100%);
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 28%, var(--lux-line, var(--border-color)));
border-radius: var(--lux-r-sm, 3px);
color: var(--ch);
cursor: default;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
overflow: hidden;
isolation: isolate;
font: inherit;
line-height: 0;
}
button.mod-icon { cursor: pointer; }
.mod-icon::before {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 6px;
height: 6px;
border-top: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
border-right: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
opacity: 0.7;
pointer-events: none;
}
.mod-icon::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(180deg,
rgba(255, 255, 255, 0.02) 0 1px,
transparent 1px 3px);
mix-blend-mode: overlay;
opacity: 0.45;
}
.mod-icon svg {
width: 24px;
height: 24px;
stroke: currentColor;
fill: none;
z-index: 1;
transition: transform 0.25s ease;
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--ch) 30%, transparent));
}
button.mod-icon:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--ch) 60%, var(--lux-line, var(--border-color)));
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 24%, transparent),
inset 0 -10px 18px color-mix(in srgb, var(--ch) 10%, transparent),
0 0 0 3px color-mix(in srgb, var(--ch) 18%, transparent),
0 4px 12px color-mix(in srgb, var(--ch) 22%, transparent);
}
button.mod-icon:hover svg { transform: scale(1.06); }
button.mod-icon:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 60%, transparent);
}
/* Empty / placeholder plate kept visible so the slot is discoverable.
Styled as a dashed outline with a quiet "+" glyph; tints to --ch on
hover so the user sees it light up just like a populated plate. */
.mod-icon.is-empty {
background: transparent;
border-style: dashed;
border-color: var(--lux-line-bold, var(--border-color));
color: var(--lux-ink-mute, var(--text-secondary));
box-shadow: none;
}
.mod-icon.is-empty::before,
.mod-icon.is-empty::after { display: none; }
.mod-icon.is-empty svg {
width: 18px;
height: 18px;
filter: none;
opacity: 0.7;
}
button.mod-icon.is-empty:hover {
transform: none;
border-color: color-mix(in srgb, var(--ch) 60%, var(--lux-line-bold, var(--border-color)));
color: var(--ch);
background: color-mix(in srgb, var(--ch) 5%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 14%, transparent);
}
button.mod-icon.is-empty:hover svg { transform: none; opacity: 1; }
.is-running .mod-icon.is-empty,
.card-running .mod-icon.is-empty { animation: none; }
/* Running cards: the plate breathes with the live indicator. */
.is-running .mod-icon,
.card-running .mod-icon {
animation: modIconPulse 2.6s ease-in-out infinite;
}
@keyframes modIconPulse {
0%, 100% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
}
50% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 22%, transparent),
inset 0 -8px 18px color-mix(in srgb, var(--ch) 12%, transparent),
0 0 14px color-mix(in srgb, var(--ch) 28%, transparent);
}
}
.mod-leds .led {
width: 6px;
height: 6px;
@@ -821,8 +958,8 @@
.dashboard-integrations-grid,
.dashboard-autostart-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min-narrow, 320px), 100%), 1fr));
gap: var(--card-grid-gap-narrow, 12px);
}
/* Legacy row-style overrides kept for any card that still lacks .mod-head */
+81 -15
View File
@@ -403,38 +403,104 @@ h2 {
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent); }
}
/* ── Update banner ── */
/* Update banner
Channel-signal surface: top accent stripe + hairline border tinted to
--ch-signal so it reads as part of the same Lumenworks language as
toasts, modals, and the bulk toolbar. Version is rendered as an
Orbitron chip same family as the version badge in the header. */
.update-banner {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 6px 16px;
background: var(--bg-secondary);
border-bottom: 2px solid var(--primary-color);
color: var(--text-color);
gap: 14px;
padding: 8px 16px;
background: linear-gradient(180deg,
var(--lux-bg-1, var(--bg-secondary)) 0%,
var(--lux-bg-2, var(--bg-secondary)) 100%);
border-bottom: var(--lux-hairline, 1px) solid color-mix(in srgb,
var(--ch-signal, var(--primary-color)) 35%,
var(--lux-line-bold, var(--border-color)));
color: var(--lux-ink, var(--text-color));
font-size: 0.85rem;
font-weight: 600;
font-weight: var(--weight-semibold, 600);
letter-spacing: 0.01em;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.02),
0 0 24px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
animation: bannerSlideDown 0.3s var(--ease-out);
}
/* Top accent stripe matches .toast::before so the channel language
stays consistent across every floating/notification surface. */
.update-banner::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1.5px;
background: linear-gradient(90deg,
transparent 0%,
var(--ch-signal, var(--primary-color)) 20%,
var(--ch-signal, var(--primary-color)) 80%,
transparent 100%);
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent);
pointer-events: none;
}
.update-banner-text {
color: var(--primary-color);
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
color: var(--lux-ink, var(--text-color));
}
.update-banner-version {
font-family: var(--font-brand, 'Orbitron', sans-serif);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ch-signal, var(--primary-color));
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
border: var(--lux-hairline, 1px) solid color-mix(in srgb,
var(--ch-signal, var(--primary-color)) 45%, transparent);
padding: 2px 8px;
border-radius: var(--lux-r-sm, 3px);
line-height: 1.4;
white-space: nowrap;
}
.update-banner-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.update-banner-action {
padding: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
color: var(--text-secondary);
border: var(--lux-hairline, 1px) solid transparent;
color: var(--lux-ink-dim, var(--text-secondary));
cursor: pointer;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
border-radius: var(--lux-r-sm, var(--radius-sm));
transition: color 0.15s, background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.update-banner-action:hover {
color: var(--primary-color);
background: var(--border-color);
color: var(--ch-signal, var(--primary-color));
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent);
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent);
}
.update-banner-apply {
color: var(--ch-signal, var(--primary-color));
}
/* ── Donation banner ── */
+348
View File
@@ -5349,3 +5349,351 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.status-card-actions .btn { flex: 1; }
}
/* ===================================================================
Icon picker modal (#icon-picker-modal)
See: features/icon-picker.ts and modals/icon-picker.html
=================================================================== */
#icon-picker-modal .modal-content {
--modal-ch: var(--ch-cyan, var(--primary-color));
max-width: 720px;
width: 100%;
}
.icon-picker-head {
display: flex !important;
align-items: center;
gap: 14px;
padding: 18px 22px 14px !important;
}
.icon-picker-preview-wrap {
flex: 0 0 56px;
}
.icon-picker-preview {
--plate-size: 56px;
width: 56px;
height: 56px;
cursor: default !important;
--ch: var(--ch-cyan, var(--primary-color));
}
.icon-picker-preview svg {
width: 30px;
height: 30px;
}
.icon-picker-preview.is-empty {
border-style: dashed !important;
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 {
font-family: var(--font-mono, monospace);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ch-cyan, var(--primary-color));
margin-bottom: 4px;
}
.icon-picker-title {
font-family: var(--font-display, inherit);
font-size: 1.4rem !important;
font-weight: 800 !important;
letter-spacing: -0.005em;
margin: 0;
color: var(--lux-ink, var(--text-color)) !important;
}
.icon-picker-sub {
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
color: var(--lux-ink-mute, var(--text-secondary));
letter-spacing: 0.06em;
margin-top: 3px;
}
.icon-picker-sub strong {
color: var(--lux-ink, var(--text-color));
font-weight: 700;
}
.icon-picker-body {
padding: 0 !important;
background: var(--lux-bg-1, var(--card-bg));
}
.icon-picker-toolbar {
display: flex;
gap: 10px;
padding: 12px 22px;
background: var(--lux-bg-0, var(--bg-color));
border-bottom: 1px solid var(--lux-line, var(--border-color));
align-items: center;
}
.icon-picker-search-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--lux-bg-1, var(--card-bg));
border: 1px solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-sm, var(--radius-sm));
}
.icon-picker-search-wrap:focus-within {
border-color: color-mix(in srgb, var(--ch-cyan) 50%, var(--lux-line-bold));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-cyan) 15%, transparent);
}
.icon-picker-search-icon { color: var(--lux-ink-mute, var(--text-secondary)); flex-shrink: 0; }
.icon-picker-search-wrap input {
flex: 1;
background: none;
border: none;
outline: none;
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
letter-spacing: 0.04em;
color: var(--lux-ink, var(--text-color));
}
.icon-picker-search-wrap input::placeholder {
color: var(--lux-ink-mute, var(--text-secondary));
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.66rem;
}
.icon-picker-color-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--lux-bg-1, var(--card-bg));
border: 1px solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-sm, var(--radius-sm));
cursor: pointer;
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-dim, var(--text-color));
transition: color 0.15s, background 0.15s;
}
.icon-picker-color-toggle:hover { color: var(--lux-ink, var(--text-color)); }
.icon-picker-swatch {
width: 14px;
height: 14px;
border-radius: 2px;
background: var(--ch-cyan, var(--primary-color));
border: 1px solid var(--ch-cyan, var(--primary-color));
}
.icon-picker-tabs {
display: flex;
gap: 0;
padding: 0 22px;
background: var(--lux-bg-0, var(--bg-color));
border-bottom: 1px solid var(--lux-line, var(--border-color));
overflow-x: auto;
}
.icon-picker-tab {
appearance: none;
background: none;
border: none;
padding: 11px 16px;
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.icon-picker-tab .count {
font-size: 0.55rem;
color: var(--lux-ink-faint, var(--text-muted));
letter-spacing: 0.05em;
background: var(--lux-bg-2, var(--card-bg));
padding: 1px 5px;
border-radius: 99px;
}
.icon-picker-tab:hover { color: var(--lux-ink-dim, var(--text-color)); }
.icon-picker-tab.is-active {
color: var(--ch-cyan, var(--primary-color));
border-bottom-color: var(--ch-cyan, var(--primary-color));
}
.icon-picker-tab.is-active .count {
background: color-mix(in srgb, var(--ch-cyan) 14%, transparent);
color: var(--ch-cyan, var(--primary-color));
}
.icon-picker-recent {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 22px;
border-bottom: 1px solid var(--lux-line, var(--border-color));
background: var(--lux-bg-0, var(--bg-color));
}
.icon-picker-recent__label {
font-family: var(--font-mono, monospace);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
flex-shrink: 0;
}
.icon-picker-recent__strip {
display: flex;
gap: 6px;
flex: 1;
overflow-x: auto;
}
.icon-picker-recent .icon-tile { width: 30px; height: 30px; }
.icon-picker-recent .icon-tile svg { width: 16px; height: 16px; }
.icon-picker-grid-wrap {
padding: 16px 22px 14px;
max-height: 380px;
overflow-y: auto;
background: var(--lux-bg-1, var(--card-bg));
}
.icon-picker-cat {
font-family: var(--font-mono, monospace);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
margin: 14px 0 10px;
display: flex;
align-items: center;
gap: 10px;
}
.icon-picker-cat:first-child { margin-top: 0; }
.icon-picker-cat::after {
content: '';
flex: 1;
height: 1px;
background: var(--lux-line, var(--border-color));
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 6px;
}
.icon-tile {
--ch: var(--ch-cyan, var(--primary-color));
appearance: none;
border: 1px solid var(--lux-line, var(--border-color));
width: 100%;
aspect-ratio: 1;
background: var(--lux-bg-0, var(--bg-color));
border-radius: 3px;
color: var(--lux-ink-dim, var(--text-color));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s, box-shadow 0.15s;
position: relative;
padding: 0;
}
.icon-tile svg {
width: 20px;
height: 20px;
stroke: currentColor;
stroke-width: 1.6;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon-tile:hover {
color: var(--ch);
border-color: color-mix(in srgb, var(--ch) 50%, var(--lux-line));
background: color-mix(in srgb, var(--ch) 8%, var(--lux-bg-0));
box-shadow: 0 0 12px color-mix(in srgb, var(--ch) 22%, transparent);
}
.icon-tile.is-selected {
color: var(--ch);
border-color: var(--ch);
background: color-mix(in srgb, var(--ch) 16%, var(--lux-bg-0));
box-shadow:
0 0 16px color-mix(in srgb, var(--ch) 35%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--ch) 35%, transparent);
}
.icon-tile.is-selected::after {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ch);
box-shadow: 0 0 6px var(--ch);
}
.icon-picker-empty {
padding: 28px 0;
text-align: center;
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
}
.icon-picker-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 22px !important;
background: var(--lux-bg-0, var(--bg-color));
border-top: 1px solid var(--lux-line, var(--border-color));
}
.icon-picker-hint {
font-family: var(--font-mono, monospace);
font-size: 0.6rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
margin-right: auto;
}
.icon-picker-foot .btn { flex: 0 0 auto; min-width: 0; }
.icon-picker-btn-remove {
color: var(--ch-coral, var(--danger-color)) !important;
border-color: color-mix(in srgb, var(--ch-coral) 40%, var(--lux-line-bold)) !important;
}
.icon-picker-btn-remove:hover:not(:disabled) {
background: color-mix(in srgb, var(--ch-coral) 12%, transparent) !important;
}
@media (max-width: 600px) {
#icon-picker-modal .modal-content { max-width: 96%; }
.icon-picker-head { flex-wrap: wrap; }
.icon-picker-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.icon-picker-toolbar { flex-direction: column; align-items: stretch; }
}
+2 -2
View File
@@ -4,8 +4,8 @@
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(var(--templates-grid-min, 350px), 1fr));
gap: var(--templates-grid-gap, 20px);
}
.template-card {
+10
View File
@@ -45,6 +45,9 @@ import {
turnOffDevice, pingDevice, removeDevice, loadDevices,
updateSettingsBaudFpsHint, copyWsUrl,
} from './features/devices.ts';
// Side-effect import: attaches the document-level click delegation
// for [data-icon-picker-trigger="<deviceId>"] (icon plate + kebab item).
import './features/icon-picker.ts';
import {
loadDashboard, stopUptimeTimer,
dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
@@ -54,6 +57,9 @@ import {
import {
hydrateDashboardLayoutFromCache, syncDashboardLayoutFromServer,
} from './features/dashboard-layout.ts';
import {
hydrateCardModesFromCache, syncCardModesFromServer,
} from './features/card-modes.ts';
import {
openDashboardCustomize, closeDashboardCustomize,
} from './features/dashboard-customize.ts';
@@ -232,6 +238,7 @@ import {
loadUpdateSettings, saveUpdateSettings, dismissUpdate,
initUpdateSettingsPanel, applyUpdate,
openReleaseNotes, closeReleaseNotes,
switchSettingsTabToUpdate,
} from './features/update.ts';
import {
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
@@ -646,6 +653,7 @@ Object.assign(window, {
applyUpdate,
openReleaseNotes,
closeReleaseNotes,
switchSettingsTabToUpdate,
// donation
dismissDonation,
@@ -723,6 +731,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// already reflects the user's saved customizations (no flash of
// default-then-custom). Server sync runs after auth.
hydrateDashboardLayoutFromCache();
hydrateCardModesFromCache();
// Initialize locale (dispatches languageChanged which may trigger API calls)
await initLocale();
@@ -823,6 +832,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// across browsers). Fire-and-forget — the cached layout is already
// active; this overwrites it if the server has a newer copy.
syncDashboardLayoutFromServer();
syncCardModesFromServer();
// Trigger the active tab's loader — initTabs() ran before authRequired
// was known, so its conditional loader call may have been skipped.
@@ -0,0 +1,47 @@
/**
* Card icon plate helper builds the ``iconHtml`` / ``iconColor`` /
* ``iconAttrs`` slots for a mod-card head from any entity that has
* optional ``icon`` and ``icon_color`` string fields.
*
* Usage:
*
* import { makeCardIconFields } from '../core/card-icon.ts';
*
* const mod: ModCardOpts = {
* head: {
* badge: { text: 'WEATHER · IN' },
* name: source.name,
* ...makeCardIconFields('weather_source', source.id, source),
* // ...
* },
* };
*
* The icon picker is opened by document-level click delegation on
* ``[data-icon-picker-trigger]``; each feature module registers its
* adapter via ``registerIconEntityType()`` from ``icon-picker.ts``.
*/
import { renderDeviceIconSvg } from './device-icons.ts';
import type { ModHeadOpts } from './mod-card.ts';
export interface IconableEntity {
icon?: string;
icon_color?: string;
}
type IconHeadFields = Pick<ModHeadOpts, 'iconHtml' | 'iconColor' | 'iconAttrs'>;
export function makeCardIconFields(
entityType: string,
entityId: string,
entity: IconableEntity,
opts: { size?: number } = {},
): IconHeadFields {
const iconId = entity.icon ?? '';
const iconColor = entity.icon_color || undefined;
return {
iconHtml: iconId ? renderDeviceIconSvg(iconId, { size: opts.size ?? 24 }) : '',
iconColor,
iconAttrs: { 'data-icon-picker-trigger': `${entityType}:${entityId}` },
};
}
@@ -24,6 +24,7 @@
import { t } from './i18n.ts';
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from './icons.ts';
import { mountCardModeToggle } from '../features/card-modes.ts';
export interface BulkAction {
key: string;
@@ -144,6 +145,7 @@ export class CardSection {
_pendingReconcile: CardItem[] | null;
_animated: boolean;
_showHidden: boolean;
_cardModeUnsubscribe: (() => void) | null;
constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) {
this.sectionKey = sectionKey;
@@ -167,6 +169,7 @@ export class CardSection {
this._pendingReconcile = null;
this._animated = false;
this._showHidden = false;
this._cardModeUnsubscribe = null;
_sectionRegistry.set(sectionKey, this);
}
@@ -214,6 +217,7 @@ export class CardSection {
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
${hiddenToggle}
${this.bulkActions ? `<button type="button" class="cs-bulk-toggle" data-cs-bulk="${this.sectionKey}" title="${t('bulk.select')}">${ICON_LIST_CHECKS}</button>` : ''}
<span class="cs-mode-slot" data-cs-mode-slot="${this.sectionKey}"></span>
<div class="cs-filter-wrap">
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off">
@@ -236,11 +240,30 @@ export class CardSection {
if (this.collapsible) {
header.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).closest('.cs-filter-wrap') || (e.target as HTMLElement).closest('.cs-header-extra')) return;
const tgt = e.target as HTMLElement;
if (tgt.closest('.cs-filter-wrap') || tgt.closest('.cs-header-extra') || tgt.closest('.cs-mode-slot')) return;
this._toggleCollapse(header, content);
});
}
// Card-mode segmented toggle — mounts into the placeholder slot
// in the header. Re-mounted on every bind() because the DOM has
// been recreated; old subscriptions are torn down first to keep
// the listener Set bounded.
if (this._cardModeUnsubscribe) {
this._cardModeUnsubscribe();
this._cardModeUnsubscribe = null;
}
const wrapper = document.querySelector(`[data-card-section="${this.sectionKey}"]`) as HTMLElement | null;
const modeSlot = document.querySelector(`[data-cs-mode-slot="${this.sectionKey}"]`) as HTMLElement | null;
if (wrapper && modeSlot) {
this._cardModeUnsubscribe = mountCardModeToggle({
container: modeSlot,
surface: this.sectionKey,
host: wrapper,
});
}
if (filterInput) {
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`) as HTMLElement | null;
const updateResetVisibility = () => {
@@ -10,6 +10,7 @@ import {
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
} from './icons.ts';
import { renderDeviceIcon } from './device-icons.ts';
import { getCardColor } from './card-colors.ts';
import { graphNavigateToNode } from '../features/graph-editor.ts';
import { showToast } from './ui.ts';
@@ -39,15 +40,28 @@ function _buildItems(results: any[], states: any = {}) {
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
const items: any[] = [];
// Map device id → device, used to inherit a device's custom icon onto
// its LED targets when the target itself doesn't override it.
const deviceMap: Record<string, any> = {};
if (Array.isArray(devices)) {
for (const d of devices) deviceMap[d.id] = d;
}
_mapEntities(devices, d => items.push({
name: d.name, detail: d.device_type, group: 'devices', icon: ICON_DEVICE,
name: d.name, detail: d.device_type, group: 'devices',
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
nav: ['targets', 'led-devices', 'led-devices', 'data-device-id', d.id],
}));
_mapEntities(targets, tgt => {
const running = !!states[tgt.id]?.processing;
let resolvedIcon = renderDeviceIcon(tgt.icon);
if (!resolvedIcon && tgt.target_type === 'led' && tgt.device_id) {
resolvedIcon = renderDeviceIcon(deviceMap[tgt.device_id]?.icon);
}
items.push({
name: tgt.name, detail: tgt.target_type, group: 'targets', icon: ICON_TARGET,
name: tgt.name, detail: tgt.target_type, group: 'targets',
icon: resolvedIcon || ICON_TARGET,
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
});
// Action item: toggle start/stop
@@ -0,0 +1,160 @@
/**
* Curated icon library for the card-icon picker.
*
* Each entry is `{ id, paths, label, aliases, category }` where ``id`` is the
* stable string persisted on the entity (e.g. ``device.icon = "mouse"``) and
* ``paths`` is the inner SVG markup (re-uses ``icon-paths.ts``).
*
* To add a new icon: import its path constant from ``icon-paths.ts`` (or add
* it there first), then append a new entry below with a stable id, label,
* search aliases, and category.
*
* The id is the contract never rename without a migration.
*/
import * as P from './icon-paths.ts';
export type IconCategory =
| 'hardware'
| 'lighting'
| 'rooms'
| 'media'
| 'signal'
| 'ambience';
export interface DeviceIconDef {
/** Stable identifier — persisted on the entity. */
id: string;
/** Inner SVG markup (no <svg> wrapper). */
paths: string;
/** Human-readable label (i18n key under ``device.icon.<id>`` if present). */
label: string;
/** Lowercase search aliases — matched as substrings against the query. */
aliases: string[];
/** Category bucket for the picker tabs. */
category: IconCategory;
}
export const CATEGORIES: { id: IconCategory; label: string; i18n: string }[] = [
{ id: 'hardware', label: 'Hardware', i18n: 'device.icon.cat.hardware' },
{ id: 'lighting', label: 'Lighting', i18n: 'device.icon.cat.lighting' },
{ id: 'rooms', label: 'Rooms', i18n: 'device.icon.cat.rooms' },
{ id: 'media', label: 'Media', i18n: 'device.icon.cat.media' },
{ id: 'signal', label: 'Signal', i18n: 'device.icon.cat.signal' },
{ id: 'ambience', label: 'Ambience', i18n: 'device.icon.cat.ambience' },
];
export const DEVICE_ICONS: DeviceIconDef[] = [
// Hardware
{ id: 'motherboard', paths: P.circuitBoard, label: 'Motherboard', aliases: ['mainboard', 'pcb', 'circuit', 'board', 'mobo'], category: 'hardware' },
{ id: 'cpu', paths: P.cpu, label: 'CPU', aliases: ['processor', 'chip'], category: 'hardware' },
{ id: 'ram', paths: P.layers, label: 'RAM', aliases: ['memory', 'dimm', 'stick'], category: 'hardware' },
{ id: 'ssd', paths: P.hardDrive, label: 'Storage', aliases: ['ssd', 'hdd', 'drive', 'disk'], category: 'hardware' },
{ id: 'mouse', paths: P.mouse, label: 'Mouse', aliases: ['rodent', 'pointer'], category: 'hardware' },
{ id: 'keyboard', paths: P.keyboard, label: 'Keyboard', aliases: ['keys', 'kbd'], category: 'hardware' },
{ id: 'controller', paths: P.gamepad2, label: 'Controller', aliases: ['gamepad', 'pad', 'joystick'], category: 'hardware' },
{ id: 'headphones', paths: P.headphones, label: 'Headphones', aliases: ['headset', 'cans'], category: 'hardware' },
{ id: 'usb', paths: P.usb, label: 'USB', aliases: ['cable', 'connector'], category: 'hardware' },
{ id: 'plug', paths: P.plug, label: 'Power plug', aliases: ['outlet', 'socket'], category: 'hardware' },
// Lighting
{ id: 'bulb', paths: P.lightbulb, label: 'Bulb', aliases: ['lamp', 'light', 'lightbulb'], category: 'lighting' },
{ id: 'strip', paths: P.rainbow, label: 'LED strip', aliases: ['strip', 'tape', 'rgb', 'wled'], category: 'lighting' },
{ id: 'panel', paths: P.layoutDashboard, label: 'LED panel', aliases: ['matrix', 'tile', 'wall'], category: 'lighting' },
{ id: 'spot', paths: P.zap, label: 'Spotlight', aliases: ['flash', 'beam', 'flood'], category: 'lighting' },
{ id: 'lamp', paths: P.flaskConical, label: 'Floor lamp', aliases: ['standing', 'pendant'], category: 'lighting' },
{ id: 'power', paths: P.power, label: 'Power', aliases: ['onoff', 'switch', 'standby'], category: 'lighting' },
{ id: 'palette', paths: P.palette, label: 'Palette', aliases: ['color', 'colour', 'paint'], category: 'lighting' },
// Rooms
{ id: 'bed', paths: P.bed, label: 'Bedroom', aliases: ['sleep', 'bedroom'], category: 'rooms' },
{ id: 'sofa', paths: P.armchair, label: 'Living room', aliases: ['armchair', 'couch', 'lounge'], category: 'rooms' },
{ id: 'desk', paths: P.layoutDashboard, label: 'Desk', aliases: ['office', 'workstation'], category: 'rooms' },
{ id: 'door', paths: P.doorOpen, label: 'Door', aliases: ['entry', 'doorway'], category: 'rooms' },
{ id: 'home', paths: P.home, label: 'Home', aliases: ['house', 'household'], category: 'rooms' },
{ id: 'fan', paths: P.fan, label: 'Fan', aliases: ['cooling', 'air'], category: 'rooms' },
{ id: 'thermostat', paths: P.thermometer, label: 'Thermostat', aliases: ['temperature', 'heating', 'climate'], category: 'rooms' },
// Media
{ id: 'monitor', paths: P.monitor, label: 'Monitor', aliases: ['display', 'screen'], category: 'media' },
{ id: 'tv', paths: P.tv, label: 'TV', aliases: ['television'], category: 'media' },
{ id: 'camera', paths: P.camera, label: 'Camera', aliases: ['cam', 'webcam'], category: 'media' },
{ id: 'mic', paths: P.mic, label: 'Microphone', aliases: ['mic', 'audio in'], category: 'media' },
{ id: 'speaker', paths: P.volume2, label: 'Speaker', aliases: ['audio', 'output', 'monitor'], category: 'media' },
{ id: 'music', paths: P.music, label: 'Music', aliases: ['note', 'audio'], category: 'media' },
{ id: 'film', paths: P.film, label: 'Film', aliases: ['video', 'movie', 'reel'], category: 'media' },
// Signal
{ id: 'wifi', paths: P.wifi, label: 'Wi-Fi', aliases: ['wireless', 'network'], category: 'signal' },
{ id: 'bluetooth', paths: P.bluetooth, label: 'Bluetooth', aliases: ['bt', 'wireless'], category: 'signal' },
{ id: 'radio', paths: P.radio, label: 'Radio', aliases: ['rf', 'antenna', 'broadcast'], category: 'signal' },
{ id: 'globe', paths: P.globe, label: 'Network', aliases: ['internet', 'web', 'world'], category: 'signal' },
{ id: 'cloud', paths: P.cloudSun, label: 'Cloud', aliases: ['weather', 'mqtt'], category: 'signal' },
{ id: 'gps', paths: P.mapPin, label: 'Location', aliases: ['map', 'gps', 'pin', 'place'], category: 'signal' },
// Ambience
{ id: 'sun', paths: P.sun, label: 'Sun', aliases: ['daylight', 'sunny', 'bright'], category: 'ambience' },
{ id: 'moon', paths: P.moon, label: 'Moon', aliases: ['night', 'dark'], category: 'ambience' },
{ id: 'flame', paths: P.flame, label: 'Flame', aliases: ['fire', 'candle', 'warm'], category: 'ambience' },
{ id: 'leaf', paths: P.leaf, label: 'Leaf', aliases: ['plant', 'eco', 'nature', 'green'], category: 'ambience' },
{ id: 'star', paths: P.star, label: 'Star', aliases: ['favorite', 'special'], category: 'ambience' },
{ id: 'sparkles', paths: P.sparkles, label: 'Sparkles', aliases: ['effect', 'magic', 'glow'], category: 'ambience' },
{ id: 'gamepad', paths: P.gamepad2, label: 'Game', aliases: ['gaming', 'play'], category: 'ambience' },
{ id: 'heart', paths: P.heart, label: 'Heart', aliases: ['love', 'favorite'], category: 'ambience' },
];
const _byId: Record<string, DeviceIconDef> = Object.fromEntries(
DEVICE_ICONS.map((d) => [d.id, d]),
);
/** Lookup an icon definition by its persisted id. Returns null if unknown. */
export function getDeviceIconDef(iconId: string | undefined | null): DeviceIconDef | null {
if (!iconId) return null;
return _byId[iconId] ?? null;
}
/** Render an icon by id as inline SVG, or empty string if the id is unknown.
* Caller decides the wrapper typically wraps it in an instrument-panel
* ``.mod-icon`` plate. */
export function renderDeviceIconSvg(iconId: string | undefined | null, opts: { size?: number; strokeWidth?: number } = {}): string {
const def = getDeviceIconDef(iconId);
if (!def) return '';
const size = opts.size ?? 28;
const sw = opts.strokeWidth ?? 1.6;
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${def.paths}</svg>`;
}
/** Render an icon by id as a `.icon`-class SVG that scales with its CSS
* context (matches the shape of `_svg()` in icons.ts). Returns empty
* string if the id is unknown so callers can chain `|| FALLBACK`. */
export function renderDeviceIcon(iconId: string | undefined | null): string {
const def = getDeviceIconDef(iconId);
if (!def) return '';
return `<svg class="icon" viewBox="0 0 24 24">${def.paths}</svg>`;
}
/** All icons as a flat list, ordered by category then by definition order. */
export function allIcons(): DeviceIconDef[] {
return DEVICE_ICONS.slice();
}
/** Filter icons by a free-text query (matches id, label, aliases). */
export function filterIcons(query: string): DeviceIconDef[] {
const q = query.trim().toLowerCase();
if (!q) return DEVICE_ICONS.slice();
return DEVICE_ICONS.filter((d) => {
if (d.id.includes(q)) return true;
if (d.label.toLowerCase().includes(q)) return true;
return d.aliases.some((a) => a.includes(q));
});
}
/** Group icons by category. Returns categories in display order. */
export function iconsByCategory(): { category: IconCategory; label: string; i18n: string; items: DeviceIconDef[] }[] {
return CATEGORIES.map((c) => ({
category: c.id,
label: c.label,
i18n: c.i18n,
items: DEVICE_ICONS.filter((d) => d.category === c.id),
}));
}
@@ -50,6 +50,25 @@ interface EntityPalettePickOpts {
let _instance: EntityPalette | null = null;
/** Drop leading/trailing/consecutive header rows so the list never shows
* an empty section or a stray divider. */
function _stripDanglingHeaders<T extends IconSelectItem>(items: T[]): T[] {
const result: T[] = [];
let pendingHeader: T | null = null;
for (const it of items) {
if (it.header) {
pendingHeader = it;
continue;
}
if (pendingHeader) {
result.push(pendingHeader);
pendingHeader = null;
}
result.push(it);
}
return result;
}
export class EntityPalette {
_overlay: HTMLDivElement;
_input: HTMLInputElement;
@@ -106,9 +125,8 @@ export class EntityPalette {
const idxStr = target.dataset.idx;
if (idxStr === undefined) return;
const idx = parseInt(idxStr, 10);
if (Number.isFinite(idx) && this._filtered[idx]) {
this._select(this._filtered[idx]);
}
const item = Number.isFinite(idx) ? this._filtered[idx] : null;
if (item && !item.header) this._select(item);
});
}
@@ -142,13 +160,25 @@ export class EntityPalette {
_filter() {
const query = this._input.value.toLowerCase().trim();
const all = this._buildFullList();
this._filtered = query
? all.filter(i => i.label.toLowerCase().includes(query) || (i.desc && i.desc.toLowerCase().includes(query)))
: all;
if (query) {
// Headers are dropped while filtering — they only structure the unfiltered list.
this._filtered = all.filter(i =>
!i.header &&
(i.label.toLowerCase().includes(query) ||
(i.desc && i.desc.toLowerCase().includes(query)))
);
} else {
// Trim leading / trailing / consecutive headers so we never show an empty section
// or a stray divider when the items list shrinks.
this._filtered = _stripDanglingHeaders(all);
}
// Highlight current value, or first item
this._highlightIdx = this._filtered.findIndex(i => i.value === this._currentValue);
if (this._highlightIdx === -1) this._highlightIdx = 0;
// Highlight current value or first selectable item
this._highlightIdx = this._filtered.findIndex(i => i.value === this._currentValue && !i.header);
if (this._highlightIdx === -1) {
this._highlightIdx = this._filtered.findIndex(i => !i.header);
if (this._highlightIdx === -1) this._highlightIdx = 0;
}
this._render();
}
@@ -167,11 +197,19 @@ export class EntityPalette {
reconcileList(
this._list,
this._filtered,
(item, i) => `${i}:${item.value}`,
(item, i) => `${i}:${item.header ? 'h:' : ''}${item.value || item.label}`,
(item, i) => {
const div = document.createElement('div');
div.className = 'entity-palette-item';
div.dataset.idx = String(i);
if (item.header) {
div.className = 'entity-palette-header';
const lbl = document.createElement('span');
lbl.className = 'ep-header-label';
lbl.textContent = item.label;
div.appendChild(lbl);
return div;
}
div.className = 'entity-palette-item';
if (item.icon) {
const ic = document.createElement('span');
ic.className = 'ep-item-icon';
@@ -192,6 +230,7 @@ export class EntityPalette {
},
(el, item, i) => {
el.dataset.idx = String(i);
if (item.header) return;
// Update text content if it shifted
const lbl = el.querySelector('.ep-item-label');
if (lbl && lbl.textContent !== item.label) lbl.textContent = item.label;
@@ -200,11 +239,15 @@ export class EntityPalette {
},
);
// Apply highlight/current classes
// Apply highlight/current classes (headers are never highlighted)
Array.from(this._list.children).forEach((el, i) => {
if (!(el instanceof HTMLElement)) return;
el.classList.toggle('ep-highlight', i === this._highlightIdx);
const item = this._filtered[i];
if (item?.header) {
el.classList.remove('ep-highlight', 'ep-current');
return;
}
el.classList.toggle('ep-highlight', i === this._highlightIdx);
el.classList.toggle('ep-current', item?.value === this._currentValue);
});
@@ -213,20 +256,27 @@ export class EntityPalette {
if (hl) hl.scrollIntoView({ block: 'nearest' });
}
_stepHighlight(dir: 1 | -1) {
// Walk past header rows (which are non-selectable).
const n = this._filtered.length;
let next = this._highlightIdx + dir;
while (next >= 0 && next < n && this._filtered[next]?.header) next += dir;
if (next >= 0 && next < n) this._highlightIdx = next;
}
_onKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1);
this._stepHighlight(1);
this._render();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this._highlightIdx = Math.max(this._highlightIdx - 1, 0);
this._stepHighlight(-1);
this._render();
} else if (e.key === 'Enter') {
e.preventDefault();
if (this._filtered[this._highlightIdx]) {
this._select(this._filtered[this._highlightIdx]);
}
const item = this._filtered[this._highlightIdx];
if (item && !item.header) this._select(item);
} else if (e.key === 'Escape') {
this._cancel();
} else if (e.key === 'Tab') {
@@ -125,6 +125,15 @@ export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
// Lucide: git-merge (sequence mode icon)
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
// Lucide: circuit-board (motherboard / mainboard)
export const circuitBoard = '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M11 9h4a2 2 0 0 0 2-2V3"/><circle cx="9" cy="9" r="2"/><path d="M7 21v-4a2 2 0 0 1 2-2h4"/><circle cx="15" cy="15" r="2"/>';
// Lucide: bed
export const bed = '<path d="M2 4v16"/><path d="M2 8h18a2 2 0 0 1 2 2v10"/><path d="M2 17h20"/><path d="M6 8v9"/>';
// Lucide: armchair (sofa)
export const armchair = '<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/><path d="M3 16a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM5 21v2M19 21v2"/>';
// Lucide: leaf
export const leaf = '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19.2 2.96c1 4.34.06 9.65-3.4 13.04A6.96 6.96 0 0 1 11 20z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>';
// Easing curve glyphs — custom mini-charts that draw the actual curve.
// Curve travels from (4, 20) to (20, 4); each path renders the easing
// function directly so the picker shows the shape, not a metaphor.
@@ -50,6 +50,8 @@ export interface IconSelectItem {
icon: string;
label: string;
desc?: string;
/** Render as a non-selectable section header (used by EntityPalette for grouping). */
header?: boolean;
}
export interface IconSelectOpts {
+66 -11
View File
@@ -14,7 +14,7 @@
import { t } from './i18n.ts';
import { escapeHtml } from './api.ts';
import { ICON_TRASH, ICON_CLONE, ICON_EYE_OFF, ICON_EYE, ICON_KEBAB } from './icons.ts';
import { ICON_TRASH, ICON_CLONE, ICON_EYE_OFF, ICON_EYE, ICON_KEBAB, ICON_PLUS } from './icons.ts';
import { cardColorDot } from './card-colors.ts';
export type LedState = 'on' | 'off' | 'blink' | 'fault';
@@ -125,8 +125,13 @@ export interface ModMenuItemOpts {
label: string;
/** Icon HTML */
icon?: string;
/** Inline onclick. The menu auto-closes on click. */
onclick: string;
/** Inline onclick. The menu auto-closes on click. Optional when
* ``dataAttrs`` is provided and the caller binds via delegation. */
onclick?: string;
/** Extra data attributes (e.g. ``{ 'data-icon-picker-trigger': id }``)
* used by document-level event delegation in lieu of an inline
* onclick string. */
dataAttrs?: Record<string, string>;
/** Mark as destructive (coral colour, separator above) */
danger?: boolean;
}
@@ -169,6 +174,24 @@ export interface ModHeadOpts {
* propagate the chosen colour to every card representing this
* entity. */
cardAttr?: string;
/** Optional custom-icon plate placed before .mod-id at the leading
* edge of the head row. Channel-tinted by default; set ``iconColor``
* to override with a hex string. When ``iconHtml`` is empty, no
* plate is rendered and the head reverts to the legacy badge-led
* layout.
*
* Two ways to make the plate interactive:
* - ``iconOnclick`` (legacy string onclick kept for parity with
* other mod-card slots; will be migrated in a follow-up).
* - ``iconAttrs`` (preferred): emit data attributes on the plate
* so callers can attach event delegation at the document level
* instead of polluting ``window`` with global onclick targets.
*/
iconHtml?: string;
iconColor?: string;
iconOnclick?: string;
iconAttrs?: Record<string, string>;
iconTitle?: string;
}
export interface ModBodyOpts {
@@ -219,7 +242,11 @@ function _menuHtml(menu: ModMenuOpts | null | undefined): string {
if (m.extraItems) {
for (const it of m.extraItems) {
const cls = it.danger ? 'mod-menu__item mod-menu__item--danger' : 'mod-menu__item';
items.push(`<button type="button" class="${cls}" role="menuitem" onclick="${it.onclick}">${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
const onclickAttr = it.onclick ? ` onclick="${it.onclick}"` : '';
const dataAttrs = it.dataAttrs
? ' ' + Object.entries(it.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
items.push(`<button type="button" class="${cls}" role="menuitem"${onclickAttr}${dataAttrs}>${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
}
}
@@ -250,7 +277,32 @@ function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string):
return `<span class="mod-badge">${dot}${escapeHtml(badge.text)}</span>`;
}
function _iconPlateHtml(head: ModHeadOpts): string {
const interactive = !!(head.iconOnclick || head.iconAttrs);
const isEmpty = !head.iconHtml;
// No icon set AND no picker hook → render nothing, head reverts to
// the legacy badge-only layout. With a picker hook we still render
// the slot as a dashed placeholder so the action stays discoverable.
if (isEmpty && !interactive) return '';
const styleAttr = head.iconColor && !isEmpty
? ` style="--ch:${escapeHtml(head.iconColor)};color:${escapeHtml(head.iconColor)}"`
: '';
const onclickAttr = head.iconOnclick ? ` onclick="${head.iconOnclick}"` : '';
const dataAttrs = head.iconAttrs
? ' ' + Object.entries(head.iconAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
const titleAttr = head.iconTitle ? ` title="${escapeHtml(head.iconTitle)}" aria-label="${escapeHtml(head.iconTitle)}"` : '';
const tag = interactive ? 'button' : 'div';
const typeAttr = interactive ? ' type="button"' : '';
const classAttr = isEmpty ? 'mod-icon is-empty' : 'mod-icon';
const inner = isEmpty ? ICON_PLUS : head.iconHtml;
return `<${tag} class="${classAttr}"${typeAttr}${styleAttr}${onclickAttr}${dataAttrs}${titleAttr}>${inner}</${tag}>`;
}
export function renderModHead(head: ModHeadOpts): string {
const iconHtml = _iconPlateHtml(head);
const badgeHtml = _badgeHtml(head.badge, head.entityId, head.cardAttr);
const nameHtml = `<div class="mod-name"><span>${escapeHtml(head.name)}</span>${head.healthDot || ''}</div>`;
const metaHtml = head.metaHtml ? `<div class="mod-meta">${head.metaHtml}</div>`
@@ -258,12 +310,13 @@ export function renderModHead(head: ModHeadOpts): string {
: '';
const ledsHtml = _ledsHtml(head.leds);
const menuHtml = _menuHtml(head.menu);
const headCls = iconHtml ? 'mod-head mod-head--with-icon' : 'mod-head';
// Order: id (flex:1) → kebab → LED bezel. LED status is the
// running/idle indicator and lives at the far-right corner where
// it doubles as the visual anchor of the head row. Kebab sits to
// its left as the second-most-discreet element.
return `<div class="mod-head">
// Order: optional icon plate → id (flex:1) → kebab → LED bezel.
// LED status is the running/idle indicator and lives at the far-right
// corner where it doubles as the visual anchor of the head row.
return `<div class="${headCls}">
${iconHtml}
<div class="mod-id">
${badgeHtml}
${nameHtml}
@@ -324,8 +377,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>`;
}
@@ -404,6 +404,8 @@ export interface GradientEntity {
is_builtin: boolean;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
}
export const gradientsCache = new DataCache<GradientEntity[]>({
@@ -12,9 +12,23 @@ import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import { loadPictureSources } from './streams.ts';
import type { Asset } from '../types.ts';
registerIconEntityType('asset', makeSimpleIconAdapter<Asset>({
cache: assetsCache,
endpointPrefix: '/assets',
reload: async () => {
assetsCache.invalidate();
await loadPictureSources();
},
typeLabelKey: 'device.icon.entity.asset',
typeLabelFallback: 'Asset',
cardSelectors: (id) => [`[data-card-section="assets"] [data-id="${CSS.escape(id)}"]`],
}));
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const ICON_PLAY_SOUND = _icon(P.play);
const ICON_UPLOAD = _icon(P.fileUp);
@@ -162,6 +176,7 @@ export function createAssetCard(asset: Asset): string {
name: asset.name,
metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`),
leds: ['on'],
...makeCardIconFields('asset', asset.id, asset),
menu: {
hideOnclick: `toggleCardHidden('assets','${asset.id}')`,
deleteOnclick: `deleteAsset('${asset.id}')`,
@@ -24,8 +24,22 @@ import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { FilterListManager } from '../core/filter-list.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts } from '../core/mod-card.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import { loadPictureSources } from './streams.ts';
registerIconEntityType('audio_processing_template', makeSimpleIconAdapter<any>({
cache: audioProcessingTemplatesCache,
endpointPrefix: '/audio-processing-templates',
reload: async () => {
audioProcessingTemplatesCache.invalidate();
await loadPictureSources();
},
typeLabelKey: 'device.icon.entity.audio_processing_template',
typeLabelFallback: 'Audio processing template',
cardSelectors: (id) => [`[data-apt-id="${CSS.escape(id)}"]`],
}));
// ── Module state ─────────────────────────────────────────────
let _aptTagsInput: TagInput | null = null;
@@ -286,6 +300,7 @@ export function createAudioProcessingTemplateCard(tmpl: any): string {
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`),
leds: ['off'],
...makeCardIconFields('audio_processing_template', tmpl.id, tmpl),
menu: {
duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('audio-processing-templates','${tmpl.id}')`,
@@ -16,6 +16,8 @@ import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
@@ -24,6 +26,20 @@ import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
cache: automationsCacheObj,
endpointPrefix: '/automations',
reload: async () => {
automationsCacheObj.invalidate();
if (typeof (window as any).loadAutomations === 'function') {
await (window as any).loadAutomations();
}
},
typeLabelKey: 'device.icon.entity.automation',
typeLabelFallback: 'Automation',
cardSelectors: (id) => [`[data-automation-id="${CSS.escape(id)}"]`],
}));
// ── HA rule entity cache ──
let _haRuleEntities: any[] = [];
@@ -401,6 +417,7 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
name: automation.name,
metaHtml,
leds: [ledState],
...makeCardIconFields('automation', automation.id, automation),
menu: {
duplicateOnclick: `cloneAutomation('${automation.id}')`,
hideOnclick: `toggleCardHidden('automations','${automation.id}')`,
@@ -13,6 +13,7 @@ import { Modal } from '../core/modal.ts';
import { closeTutorial, startCalibrationTutorial } from './tutorials.ts';
import { startCSSOverlay, stopCSSOverlay } from './color-strips/index.ts';
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW, ICON_DEVICE } from '../core/icons.ts';
import { renderDeviceIcon } from '../core/device-icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import type { Calibration } from '../types.ts';
@@ -294,7 +295,7 @@ export async function showCSSCalibration(cssId: any) {
getItems: () => _calTestDeviceList.map((d: any) => ({
value: d.id,
label: d.name,
icon: ICON_DEVICE,
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
desc: d.led_count ? `${d.led_count} LEDs` : '',
})),
placeholder: t('palette.search'),
@@ -0,0 +1,261 @@
/**
* Card presentation modes per-surface size toggle (comfortable / compact / dense).
*
* Sibling to `dashboard-layout.ts` but slimmer: a single open registry of
* surface keys ('devices', 'displays', 'dashboard-targets', ) each mapped
* to one of three modes. CSS does the heavy lifting via the
* `[data-card-mode="…"]` attribute (see card-modes.css).
*
* Boot sequence (matches dashboard-layout):
* 1. hydrateCardModesFromCache() synchronous, called before first paint
* 2. syncCardModesFromServer() async, runs after auth completes
*
* Persistence:
* - localStorage `card_modes_v1` cache for instant first paint
* - server `GET/PUT /preferences/card-modes` for cross-browser truth
* - PUT is debounced 300 ms; subscribers fire synchronously on save
*
* Surface keys are free-form strings anything calling `setCardMode` is
* implicitly registering that key. Defaults are returned for unknown keys.
*/
import { fetchWithAuth } from '../core/api.ts';
import { t } from '../core/i18n.ts';
const LS_KEY = 'card_modes_v1';
const SCHEMA_VERSION = 1;
export type CardMode = 'comfortable' | 'compact' | 'dense' | 'row';
export const CARD_MODES: readonly CardMode[] = ['comfortable', 'compact', 'dense', 'row'] as const;
const DEFAULT_MODE: CardMode = 'compact';
export interface CardModePrefsV1 {
version: 1;
surfaces: Record<string, CardMode>;
}
const DEFAULT_PREFS: CardModePrefsV1 = {
version: SCHEMA_VERSION,
surfaces: {},
};
let _current: CardModePrefsV1 = _clone(DEFAULT_PREFS);
let _serverSyncedOnce = false;
let _saveTimer: ReturnType<typeof setTimeout> | null = null;
const _listeners = new Set<() => void>();
function _clone(prefs: CardModePrefsV1): CardModePrefsV1 {
return { version: prefs.version, surfaces: { ...prefs.surfaces } };
}
function _isCardMode(v: unknown): v is CardMode {
return v === 'comfortable' || v === 'compact' || v === 'dense' || v === 'row';
}
/** Normalise a parsed value back into a valid prefs object, dropping
* garbage values. Tolerates older/forward versions by treating the
* surfaces map as the only authoritative payload. */
function _normalise(parsed: unknown): CardModePrefsV1 {
const out: CardModePrefsV1 = _clone(DEFAULT_PREFS);
if (!parsed || typeof parsed !== 'object') return out;
const obj = parsed as Record<string, unknown>;
const surfaces = obj.surfaces;
if (surfaces && typeof surfaces === 'object') {
for (const [k, v] of Object.entries(surfaces as Record<string, unknown>)) {
if (typeof k === 'string' && _isCardMode(v)) {
out.surfaces[k] = v;
}
}
}
return out;
}
function _notify(): void {
for (const fn of _listeners) {
try { fn(); } catch (e) { console.error('card-modes listener', e); }
}
}
/** Read the current prefs. Defensive copy. */
export function getCardModePrefs(): CardModePrefsV1 {
return _clone(_current);
}
/** Effective mode for a surface returns the configured value or the
* default when the surface is unset. */
export function getCardMode(surface: string): CardMode {
return _current.surfaces[surface] ?? DEFAULT_MODE;
}
/** Persist a surface's mode. Updates cache, fires subscribers,
* schedules debounced server PUT. */
export function setCardMode(surface: string, mode: CardMode): void {
if (!_isCardMode(mode)) return;
if (_current.surfaces[surface] === mode) return;
_current = {
version: _current.version,
surfaces: { ..._current.surfaces, [surface]: mode },
};
_persistLocal();
_notify();
_scheduleServerPush();
}
/** Subscribe to mode changes (any surface). Returns an unsubscribe fn. */
export function subscribeCardModes(fn: () => void): () => void {
_listeners.add(fn);
return () => _listeners.delete(fn);
}
function _persistLocal(): void {
try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ }
}
function _scheduleServerPush(): void {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
_saveTimer = null;
_pushToServer(_current).catch(e => console.warn('card-modes PUT failed', e));
}, 300);
}
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
try {
await fetchWithAuth('/preferences/card-modes', {
method: 'PUT',
body: JSON.stringify(prefs),
});
} catch (e) {
console.warn('card-modes server PUT failed', e);
}
}
/** Hydrate from localStorage cache. Synchronous safe to call before
* auth so the first paint already uses the user's saved modes. */
export function hydrateCardModesFromCache(): CardModePrefsV1 {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
_current = _normalise(JSON.parse(raw));
}
} catch (e) {
console.warn('card-modes cache parse failed', e);
}
return _clone(_current);
}
/** Pull prefs from server (post-auth). Replaces local cache when server
* has a saved value; otherwise pushes local cache up so other browsers
* inherit it. Safe to call repeatedly only runs the round-trip once. */
export async function syncCardModesFromServer(): Promise<void> {
if (_serverSyncedOnce) return;
try {
const resp = await fetchWithAuth('/preferences/card-modes');
if (!resp || !resp.ok) return;
const data = await resp.json();
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
_current = _normalise(data);
_persistLocal();
_notify();
} else {
// Server has nothing — push what we have so the next browser
// picks up the same view.
if (Object.keys(_current.surfaces).length > 0) {
await _pushToServer(_current);
}
}
_serverSyncedOnce = true;
} catch (e) {
console.warn('card-modes server sync failed', e);
}
}
// ─────────────────────────────────────────────────────────────────────────
// DOM helpers
// ─────────────────────────────────────────────────────────────────────────
/** Apply `data-card-mode` to a host element based on the current pref
* for `surface`. Idempotent; safe to call on the same element after a
* mode change to re-sync. */
export function applyCardModeAttr(host: HTMLElement, surface: string): void {
host.setAttribute('data-card-mode', getCardMode(surface));
}
/** Bind a host element to a surface's mode. Re-applies the attribute
* whenever the pref for that surface changes. Returns an unsubscribe
* function for use in a cleanup hook. */
export function bindCardModeAttr(host: HTMLElement, surface: string): () => void {
applyCardModeAttr(host, surface);
return subscribeCardModes(() => applyCardModeAttr(host, surface));
}
// ─────────────────────────────────────────────────────────────────────────
// Toggle UI
// ─────────────────────────────────────────────────────────────────────────
export interface MountCardModeToggleOpts {
/** Element to append the toggle to. */
container: HTMLElement;
/** Surface key whose mode this toggle controls. */
surface: string;
/** Optional host element to receive the `data-card-mode` attribute.
* Defaults to `container.parentElement ?? container`. Pass the grid
* container (or any common ancestor of the cards) so CSS overrides
* cascade correctly. */
host?: HTMLElement;
/** Position hint for screen readers + analytics. */
label?: string;
}
const _MODE_BUTTONS: ReadonlyArray<{ v: CardMode; lbl: string; key: string }> = [
{ v: 'comfortable', lbl: 'C', key: 'card_mode.comfortable' },
{ v: 'compact', lbl: 'M', key: 'card_mode.compact' },
{ v: 'dense', lbl: 'D', key: 'card_mode.dense' },
{ v: 'row', lbl: 'R', key: 'card_mode.row' },
];
/** Mount a segmented `C / M / D` toggle that drives the given surface.
* Returns a teardown function that removes the toggle and unsubscribes. */
export function mountCardModeToggle(opts: MountCardModeToggleOpts): () => void {
const host = opts.host ?? opts.container.parentElement ?? opts.container;
const surface = opts.surface;
const wrap = document.createElement('div');
wrap.className = 'card-mode-toggle';
wrap.setAttribute('role', 'radiogroup');
wrap.setAttribute('aria-label', opts.label || t('card_mode.tooltip') || 'Card size');
const buttons: HTMLButtonElement[] = _MODE_BUTTONS.map(b => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'card-mode-toggle__btn';
btn.dataset.cardMode = b.v;
btn.setAttribute('role', 'radio');
btn.textContent = b.lbl;
btn.title = t(b.key) || b.v;
btn.setAttribute('aria-label', t(b.key) || b.v);
btn.addEventListener('click', () => setCardMode(surface, b.v));
wrap.appendChild(btn);
return btn;
});
const refresh = () => {
const current = getCardMode(surface);
for (const btn of buttons) {
const isActive = btn.dataset.cardMode === current;
btn.classList.toggle('is-active', isActive);
btn.setAttribute('aria-checked', isActive ? 'true' : 'false');
}
applyCardModeAttr(host, surface);
};
refresh();
opts.container.appendChild(wrap);
const unsubscribe = subscribeCardModes(refresh);
return () => {
unsubscribe();
wrap.remove();
};
}
@@ -23,6 +23,23 @@ import type { ColorStripSource } from '../../types.ts';
import { bindableValue, bindableColor } from '../../types.ts';
import { renderTagChips } from '../../core/tag-input.ts';
import { rgbArrayToHex } from '../css-gradient-editor.ts';
import { makeCardIconFields } from '../../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from '../icon-picker.ts';
registerIconEntityType('color_strip_source', makeSimpleIconAdapter<ColorStripSource>({
cache: colorStripSourcesCache,
endpointPrefix: '/color-strip-sources',
reload: async () => {
colorStripSourcesCache.invalidate();
if (typeof (window as any).loadPictureSources === 'function') {
await (window as any).loadPictureSources();
}
},
typeLabelKey: 'device.icon.entity.color_strip_source',
typeLabelFallback: 'Color strip',
cardSelectors: (id) => [`[data-css-id="${CSS.escape(id)}"]`],
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'static' }),
}));
/* ── Types ────────────────────────────────────────────────────── */
@@ -332,6 +349,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
name: source.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
...makeCardIconFields('color_strip_source', source.id, source),
menu: {
duplicateOnclick: `cloneColorStrip('${source.id}')`,
hideOnclick: `toggleCardHidden('color-strips','${source.id}')`,
@@ -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);
@@ -1649,8 +1650,8 @@ export async function deleteColorStrip(cssId: string) {
if (window.loadTargetsTab) await window.loadTargetsTab();
} else {
const err = await response.json();
const msg = err.detail || 'Failed to delete';
showToast(response.status === 409 ? t('color_strip.delete.referenced') : `Failed: ${msg}`, 'error');
const msg = err.detail || (response.status === 409 ? t('color_strip.delete.referenced') : 'Failed to delete');
showToast(msg, 'error');
}
} catch (error: any) {
if (error.isAuth) return;
@@ -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);
});
}
@@ -16,8 +16,10 @@ import {
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
import { mountCardModeToggle } from './card-modes.ts';
function _applyGlobalLayoutAttrs(): void {
const c = document.getElementById('dashboard-content');
@@ -26,6 +28,33 @@ function _applyGlobalLayoutAttrs(): void {
c.dataset.layoutWidth = g.width;
c.dataset.layoutAnim = g.animations;
}
/** Card-mode toggle teardown registry. Each render replaces the dashboard
* inner HTML, so any previously-mounted toggle becomes detached. We tear
* down old subscribers before mounting fresh so the module-level listener
* Set in card-modes.ts stays bounded. Keyed by the surface name (matches
* `data-dashboard-mode-slot`). */
const _dashboardModeTeardowns = new Map<string, () => void>();
function _mountDashboardCardModeToggles(): void {
const container = document.getElementById('dashboard-content');
if (!container) return;
for (const [, teardown] of _dashboardModeTeardowns) {
try { teardown(); } catch (e) { console.warn('card-mode teardown', e); }
}
_dashboardModeTeardowns.clear();
const slots = container.querySelectorAll<HTMLElement>('[data-dashboard-mode-slot]');
for (const slot of slots) {
const surface = slot.dataset.dashboardModeSlot;
if (!surface) continue;
// Host = nearest [data-section] ancestor (either a .dashboard-section
// or a .dashboard-subsection — both carry the attribute now).
const host = slot.closest('[data-section]') as HTMLElement | null;
if (!host) continue;
const teardown = mountCardModeToggle({ container: slot, surface, host });
_dashboardModeTeardowns.set(surface, teardown);
}
}
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
@@ -456,8 +485,11 @@ function renderDashboardSyncClock(clock: SyncClock): string {
const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
const scStyle = cardColorStyle(clock.id);
const iconPlate = _dashboardIconPlate(clock as any);
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${clock.is_running ? 'is-running' : ''}" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="mod-head">
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">CLK · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(clock.name)}</span></div>
@@ -548,7 +580,7 @@ export function toggleDashboardSection(sectionKey: string): void {
}
}
function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = ''): string {
function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = '', cardModeSurface: string = ''): string {
const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
@@ -558,12 +590,19 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin
const countHtml = (count !== '' && count != null)
? `<span class="dashboard-section-count">${count}</span>`
: '';
// Optional card-mode toggle slot — mounted post-render in
// _mountDashboardCardModeToggles(). Sections that don't display a
// card grid (e.g. 'perf') pass an empty string and get no slot.
const modeSlot = cardModeSurface
? `<span class="dashboard-mode-slot" data-dashboard-mode-slot="${cardModeSurface}"></span>`
: '';
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
${countHtml}
</span>
${modeSlot}
${extraHtml}
</div>`;
}
@@ -820,7 +859,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)}
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`, '', 'dashboard-integrations')}
${_sectionContent('integrations', intGrid)}
</div>`;
}
@@ -834,7 +873,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length, '', 'dashboard-automations')}
${_sectionContent('automations', automationGrid)}
</div>`;
}
@@ -844,7 +883,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const sceneSec = renderScenePresetsSection(scenePresets);
if (sceneSec && typeof sceneSec === 'object') {
sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra, 'dashboard-scenes')}
${_sectionContent('scenes', sceneSec.content)}
</div>`;
}
@@ -855,7 +894,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="sync-clocks">
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)}
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length, '', 'dashboard-sync-clocks')}
${_sectionContent('sync-clocks', clockGrid)}
</div>`;
}
@@ -868,8 +907,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const stopAllBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">${ICON_STOP} ${t('dashboard.stop_all')}</button>`;
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join('');
targetsInner += `<div class="dashboard-subsection">
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
targetsInner += `<div class="dashboard-subsection" data-section="running">
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn, 'dashboard-running')}
${_sectionContent('running', runningItems)}
</div>`;
}
@@ -877,8 +916,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (stopped.length > 0) {
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join('');
targetsInner += `<div class="dashboard-subsection">
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
targetsInner += `<div class="dashboard-subsection" data-section="stopped">
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length, '', 'dashboard-stopped')}
${_sectionContent('stopped', stoppedItems)}
</div>`;
}
@@ -940,6 +979,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const el = container.querySelector(`.dashboard-section[data-section="${CSS.escape(s.key)}"]`) as HTMLElement | null;
if (el) el.dataset.density = s.density;
}
_mountDashboardCardModeToggles();
_lastRunningIds = runningIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
_cacheUptimeElements();
@@ -957,11 +997,55 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
}
}
/** Render an icon plate (`.mod-icon`) for a dashboard card from the
* entity's own ``icon`` / ``icon_color`` fields. Returns empty string
* when no icon is set, so the head reverts to its legacy badge-only
* layout. */
function _dashboardIconPlate(entity: { icon?: string; icon_color?: string }): string {
const iconId = entity.icon || '';
if (!iconId) return '';
const iconColor = entity.icon_color || '';
const styleAttr = iconColor
? ` style="--ch:${escapeHtml(iconColor)};color:${escapeHtml(iconColor)}"`
: '';
return `<div class="mod-icon"${styleAttr}>${renderDeviceIconSvg(iconId, { size: 24 })}</div>`;
}
/** Resolve the effective custom icon for a dashboard target card.
* LED targets inherit from their referenced device when no own icon is
* set; HA-light targets have no inheritance source. Returns the HTML
* for the icon plate (empty string when no icon is available). */
function _dashboardTargetIconPlate(target: any, devicesMap: Record<string, Device>): string {
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const isHALight = target.target_type === 'ha_light';
const ownIconId = (target.icon as string | undefined) || '';
const ownColor = (target.icon_color as string | undefined) || '';
let iconId = ownIconId;
let iconColor = ownColor;
if (!iconId && isLed) {
const device = target.device_id ? devicesMap[target.device_id] : null;
const inheritedId = (device as any)?.icon || '';
if (inheritedId) {
iconId = inheritedId;
iconColor = ownColor || (device as any)?.icon_color || '';
}
}
if (!iconId) return '';
const styleAttr = iconColor
? ` style="--ch:${escapeHtml(iconColor)};color:${escapeHtml(iconColor)}"`
: '';
void isHALight;
return `<div class="mod-icon"${styleAttr}>${renderDeviceIconSvg(iconId, { size: 24 })}</div>`;
}
function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Record<string, Device> = {}, cssSourceMap: Record<string, ColorStripSource> = {}): string {
const state = target.state || {};
const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const isHALight = target.target_type === 'ha_light';
const iconPlate = _dashboardTargetIconPlate(target, devicesMap);
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
const icon = ICON_TARGET;
const typeLabel = isLed ? t('dashboard.type.led') : isHALight ? t('ha_light.section.title') : t('dashboard.type.kc');
const navSubTab = isHALight ? 'ha-light-targets' : 'led-targets';
@@ -1026,7 +1110,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
const cStyle = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link is-running" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="mod-head">
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span>${healthDot}</div>
@@ -1061,7 +1146,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
} else {
const cStyle2 = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="mod-head">
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span></div>
@@ -1120,8 +1206,11 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
metaLines.push(`${ICON_SCENE} ${sceneName}`);
const aStyle = cardColorStyle(automation.id);
const iconPlate = _dashboardIconPlate(automation as any);
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
return `<div class="dashboard-target dashboard-automation dashboard-card-link ${isActive ? 'is-running' : ''}" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
<div class="mod-head">
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">AUTO · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(automation.name)}</span></div>
@@ -14,11 +14,13 @@ import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts } from '../core/mod-card.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts, ModMenuItemOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
import type { Device } from '../types.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { ICON_EDIT } from '../core/icons.ts';
let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect: any = null;
@@ -279,6 +281,24 @@ export function createDeviceCard(device: Device & { state?: any }) {
title: t('device.button.settings'),
});
// ── Custom icon plate (optional, set via the picker) ──
const iconId = (device as Device).icon;
const iconColor = (device as Device).icon_color;
const iconHtml = iconId ? renderDeviceIconSvg(iconId, { size: 24 }) : '';
const iconTitle = iconId ? t('device.icon.change') : t('device.icon.choose');
// Kebab menu — add "Change icon…" as the first extra item so the
// picker is reachable from anywhere on the card, not only the plate.
// Wired via document-level delegation (data-icon-picker-trigger),
// not an inline onclick string — see features/icon-picker.ts.
const menuExtraItems: ModMenuItemOpts[] = [
{
label: iconId ? t('device.icon.change') : t('device.icon.choose'),
icon: ICON_EDIT,
dataAttrs: { 'data-icon-picker-trigger': device.id },
},
];
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
@@ -286,7 +306,12 @@ export function createDeviceCard(device: Device & { state?: any }) {
metaHtml: metaPartsHtml.length ? metaPartsHtml.join(' · ') : undefined,
healthDot,
leds,
iconHtml,
iconColor,
iconAttrs: { 'data-icon-picker-trigger': device.id },
iconTitle,
menu: {
extraItems: menuExtraItems,
duplicateOnclick: `cloneDevice('${device.id}')`,
hideOnclick: `toggleCardHidden('led-devices','${device.id}')`,
deleteOnclick: `removeDevice('${device.id}')`,
@@ -14,6 +14,8 @@ import { CardSection } from '../core/card-sections.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
import {
ICON_GAMEPAD, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_TRASH,
@@ -25,6 +27,22 @@ import type {
EffectPreset,
} from '../types.ts';
registerIconEntityType('game_integration', makeSimpleIconAdapter<GameIntegration>({
cache: gameIntegrationsCache,
endpointPrefix: '/game-integrations',
reload: async () => {
gameIntegrationsCache.invalidate();
if (typeof (window as any).loadIntegrations === 'function') {
await (window as any).loadIntegrations();
}
},
typeLabelKey: 'device.icon.entity.game_integration',
typeLabelFallback: 'Game integration',
cardSelectors: (id) => [
`[data-card-section="game-integrations"] [data-gi-id="${CSS.escape(id)}"]`,
],
}));
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Bulk actions ──
@@ -566,6 +584,7 @@ export function createGameIntegrationCard(gi: GameIntegration): string {
name: gi.name,
metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`),
leds,
...makeCardIconFields('game_integration', gi.id, gi),
menu: {
duplicateOnclick: `cloneGameIntegration('${gi.id}')`,
hideOnclick: `toggleCardHidden('game-integrations','${gi.id}')`,
@@ -11,8 +11,10 @@ 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 { renderDeviceIconSvg } from '../core/device-icons.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 { openAuthedWs } from '../core/ws-auth.ts';
import { bindableSourceId, bindableValue } from '../types.ts';
@@ -23,17 +25,24 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Modal ──
type HALightSourceKind = 'css' | 'color_vs';
let _haLightTagsInput: TagInput | null = null;
let _haSourceEntitySelect: EntitySelect | null = null;
let _cssSourceEntitySelect: EntitySelect | null = null;
let _brightnessWidget: BindableScalarWidget | null = null;
let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = [];
let _editorColorValueSources: any[] = []; // value sources with return_type='color'
let _cachedHAEntities: any[] = []; // fetched from selected HA source
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;
// Active mode + selected colour value source id (only used in color_vs mode).
let _editorSourceKind: HALightSourceKind = 'css';
let _editorColorVsId: string = '';
class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); }
@@ -47,6 +56,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();
}
@@ -54,12 +64,14 @@ class HALightEditorModal extends Modal {
return {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
color_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
source_kind: _editorSourceKind,
brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0',
update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
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() : []),
};
@@ -77,16 +89,72 @@ function _getMappingsJSON(): string {
const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row');
const mappings: any[] = [];
rows.forEach(row => {
const ledStartEl = row.querySelector('.ha-mapping-led-start') as HTMLInputElement | null;
const ledEndEl = row.querySelector('.ha-mapping-led-end') as HTMLInputElement | null;
// In color_vs mode the LED range inputs are not rendered; fall back to
// safe defaults so mappings round-trip if the user toggles back to CSS.
mappings.push({
entity_id: (row.querySelector('.ha-mapping-entity') as HTMLSelectElement).value.trim(),
led_start: parseInt((row.querySelector('.ha-mapping-led-start') as HTMLInputElement).value) || 0,
led_end: parseInt((row.querySelector('.ha-mapping-led-end') as HTMLInputElement).value) || -1,
led_start: ledStartEl ? (parseInt(ledStartEl.value) || 0) : 0,
led_end: ledEndEl ? (parseInt(ledEndEl.value) || -1) : -1,
brightness_scale: parseFloat((row.querySelector('.ha-mapping-brightness') as HTMLInputElement).value) || 1.0,
});
});
return JSON.stringify(mappings);
}
function _isColorValueSource(vs: any): boolean {
return !!vs && vs.return_type === 'color';
}
/**
* Build the unified Color Source picker items, split into two sections:
*
* Color strip sources
* Audio bars (audio)
* Living-room screen (picture)
* Color value sources
* Sunrise cycle (animated_color)
*/
function _buildColorSourcePickerItems(): any[] {
const items: any[] = [];
if (_editorCssSources.length > 0) {
items.push({ value: '', label: t('ha_light.color_source.css'), icon: '', header: true });
for (const s of _editorCssSources) {
items.push({
value: `css:${s.id}`,
label: s.name,
icon: getColorStripIcon(s.source_type),
desc: s.source_type,
});
}
}
if (_editorColorValueSources.length > 0) {
items.push({ value: '', label: t('ha_light.color_source.color_vs'), icon: '', header: true });
for (const s of _editorColorValueSources) {
items.push({
value: `cvs:${s.id}`,
label: s.name,
icon: getValueSourceIcon(s.source_type),
desc: s.source_type,
});
}
}
return items;
}
function _setMappingsModeHint(): void {
const hintColorVs = document.getElementById('ha-light-mappings-mode-hint');
const hintCss = document.querySelectorAll('#ha-light-editor-modal small.input-hint[data-i18n="ha_light.mappings.hint"]')[0] as HTMLElement | undefined;
const visibleCss = _editorSourceKind === 'css';
if (hintColorVs) hintColorVs.style.display = visibleCss ? 'none' : '';
// CSS hint is only revealed by the toggle; do not auto-show.
if (hintCss && !visibleCss) hintCss.style.display = 'none';
}
function _getEntityItems() {
return _cachedHAEntities
.filter((e: any) => e.domain === 'light')
@@ -164,17 +232,16 @@ export function addHALightMapping(data: any = null): void {
? `<option value="${escapeHtml(selectedId)}" selected>${escapeHtml(selectedId)}</option>`
: '';
row.innerHTML = `
<div class="ha-mapping-header">
<span class="ha-mapping-label">${_icon(P.lightbulb)} #${idx}</span>
<button type="button" class="btn-remove-mapping" onclick="removeHALightMapping(this)" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="ha-mapping-fields">
<div class="ha-mapping-field">
<label>${t('ha_light.mapping.entity_id')}</label>
<select class="ha-mapping-entity">${entityOptions}${extraOption}</select>
</div>
<div class="ha-mapping-range-row">
// Per-source-kind row layout: CSS shows LED ranges; color_vs hides them and
// promotes the brightness scale to a single inline field.
const rangeBlock = _editorSourceKind === 'color_vs'
? `<div class="ha-mapping-range-row">
<div>
<label>${t('ha_light.mapping.brightness')}</label>
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
</div>
</div>`
: `<div class="ha-mapping-range-row">
<div>
<label>${t('ha_light.mapping.led_start')}</label>
<input type="number" class="ha-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
@@ -187,7 +254,19 @@ export function addHALightMapping(data: any = null): void {
<label>${t('ha_light.mapping.brightness')}</label>
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
</div>
</div>`;
row.innerHTML = `
<div class="ha-mapping-header">
<span class="ha-mapping-label">${_icon(P.lightbulb)} #${idx}</span>
<button type="button" class="btn-remove-mapping" onclick="removeHALightMapping(this)" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="ha-mapping-fields">
<div class="ha-mapping-field">
<label>${t('ha_light.mapping.entity_id')}</label>
<select class="ha-mapping-entity">${entityOptions}${extraOption}</select>
</div>
${rangeBlock}
</div>
`;
list.appendChild(row);
@@ -202,6 +281,21 @@ export function addHALightMapping(data: any = null): void {
_mappingEntitySelects.push(es);
}
/**
* Re-render every mapping row using the current `_editorSourceKind` layout.
* Snapshots existing values (entity, ranges, brightness) so the user does not
* lose data when toggling between CSS and color_vs modes.
*/
function _rerenderMappingsForMode(): void {
const list = document.getElementById('ha-light-mappings-list');
if (!list) return;
const snapshot = JSON.parse(_getMappingsJSON());
_destroyMappingEntitySelects();
list.innerHTML = '';
snapshot.forEach((m: any) => addHALightMapping(m));
_setMappingsModeHint();
}
export function removeHALightMapping(btn: HTMLElement): void {
const row = btn.closest('.ha-light-mapping-row');
if (!row) return;
@@ -271,6 +365,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({
@@ -294,6 +404,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
valueSourcesCache.fetch().catch(() => {}),
]);
_editorCssSources = cssSources;
_editorColorValueSources = (_cachedValueSources || []).filter(_isColorValueSource);
const isEdit = !!targetId;
const isClone = !!cloneData;
@@ -308,11 +419,15 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// Populate CSS source dropdown
const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement;
cssSelect.innerHTML = `<option value="">—</option>` + cssSources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
// Unified Color Source picker — combines CSS sources + colour-returning value sources.
const colorSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement;
const cssOptions = cssSources.map((s: any) =>
`<option value="css:${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`
).join('');
const colorVsOptions = _editorColorValueSources.map((s: any) =>
`<option value="cvs:${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`
).join('');
colorSelect.innerHTML = `<option value="">—</option>${cssOptions}${colorVsOptions}`;
// Clear mappings
_destroyMappingEntitySelects();
@@ -338,12 +453,20 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
if (isEdit) (document.getElementById('ha-light-editor-id') as HTMLInputElement).value = editData.id;
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
haSelect.value = editData.ha_source_id || '';
cssSelect.value = editData.color_strip_source_id || '';
_editorSourceKind = (editData.source_kind === 'color_vs') ? 'color_vs' : 'css';
_editorColorVsId = editData.color_value_source_id || '';
const editCssId = editData.color_strip_source_id || '';
colorSelect.value = _editorSourceKind === 'color_vs'
? (_editorColorVsId ? `cvs:${_editorColorVsId}` : '')
: (editCssId ? `css:${editCssId}` : '');
_ensureBrightnessWidget().setValue(editData.brightness ?? 1.0);
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
_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
@@ -354,11 +477,16 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
mappings.forEach((m: any) => addHALightMapping(m));
} else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
_editorSourceKind = 'css';
_editorColorVsId = '';
_ensureBrightnessWidget().setValue(1.0);
_ensureUpdateRateWidget().setValue(2.0);
_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
@@ -368,6 +496,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
// Add one empty mapping by default
addHALightMapping();
}
_setMappingsModeHint();
// EntitySelects
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
@@ -387,11 +516,26 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
_cssSourceEntitySelect = new EntitySelect({
target: cssSelect,
getItems: () => _editorCssSources.map((s: any) => ({
value: s.id, label: s.name, icon: getColorStripIcon(s.source_type), desc: s.source_type,
})),
target: colorSelect,
getItems: () => _buildColorSourcePickerItems(),
placeholder: t('palette.search'),
onChange: (rawValue: string) => {
// Decode "css:<id>" or "cvs:<id>" into source kind + id, then re-render
// mapping rows so the LED-range fields appear/disappear in step.
const newKind: HALightSourceKind = rawValue.startsWith('cvs:') ? 'color_vs' : 'css';
const id = rawValue.includes(':') ? rawValue.slice(rawValue.indexOf(':') + 1) : '';
if (newKind === 'color_vs') {
_editorColorVsId = id;
} else {
_editorColorVsId = '';
}
if (newKind !== _editorSourceKind) {
_editorSourceKind = newKind;
_rerenderMappingsForMode();
} else {
_setMappingsModeHint();
}
},
});
// Tags
@@ -413,11 +557,21 @@ export async function saveHALightEditor(): Promise<void> {
const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value;
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
const colorSourceRaw = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
// Decode the unified picker value: "css:<id>" or "cvs:<id>" (or "" for none).
const sourceKind: HALightSourceKind = colorSourceRaw.startsWith('cvs:') ? 'color_vs' : 'css';
const colorSourceId = colorSourceRaw.includes(':')
? colorSourceRaw.slice(colorSourceRaw.indexOf(':') + 1)
: '';
const cssSourceId = sourceKind === 'css' ? colorSourceId : '';
const colorValueSourceId = sourceKind === 'color_vs' ? colorSourceId : '';
const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0;
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) {
@@ -428,6 +582,10 @@ export async function saveHALightEditor(): Promise<void> {
haLightEditorModal.showError(t('ha_light.error.ha_source_required'));
return;
}
if (sourceKind === 'color_vs' && !colorValueSourceId) {
haLightEditorModal.showError(t('ha_light.error.color_source_required'));
return;
}
// Collect mappings
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id);
@@ -437,13 +595,16 @@ export async function saveHALightEditor(): Promise<void> {
const payload: any = {
name,
ha_source_id: haSourceId,
source_kind: sourceKind,
color_strip_source_id: cssSourceId,
color_value_source_id: colorValueSourceId,
brightness,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
color_tolerance: colorTolerance,
min_brightness_threshold: minBrightnessThreshold,
stop_action: stopAction,
description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
@@ -531,7 +692,20 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
// ── Chips ──
const chips: ModChipOpts[] = [];
if (cssSource) {
const colorVsId: string = target.color_value_source_id || '';
const colorVs = colorVsId && valueSourceMap[colorVsId] ? valueSourceMap[colorVsId] : null;
if (target.source_kind === 'color_vs') {
if (colorVs) {
chips.push({
icon: getValueSourceIcon(colorVs.source_type),
text: colorVs.name,
title: t('ha_light.color_source'),
onclick: `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${colorVsId}')`,
});
} else if (colorVsId) {
chips.push({ icon: _icon(P.palette), text: colorVsId, title: t('ha_light.color_source') });
}
} else if (cssSource) {
chips.push({
icon: getColorStripIcon(cssSource.source_type),
text: cssSource.name,
@@ -622,13 +796,36 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
},
];
// ── Custom icon plate (HA-light targets have no inheritance source) ──
const targetIconId = (target.icon as string | undefined) || '';
const targetIconColor = (target.icon_color as string | undefined) || '';
const iconHtml = targetIconId ? renderDeviceIconSvg(targetIconId, { size: 24 }) : '';
const iconTitle = targetIconId
? (t('device.icon.change') || 'Change 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 },
name: target.name,
metaHtml,
leds,
iconHtml,
iconColor: targetIconColor,
iconAttrs: { 'data-icon-picker-trigger': `target:${target.id}` },
iconTitle,
menu: {
extraItems: targetMenuExtraItems,
duplicateOnclick: `cloneHALightTarget('${target.id}')`,
hideOnclick: `toggleCardHidden('ha-light-targets','${target.id}')`,
deleteOnclick: `deleteTarget('${target.id}')`,
@@ -12,8 +12,25 @@ import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import type { HomeAssistantSource } from '../types.ts';
registerIconEntityType('ha_source', makeSimpleIconAdapter<HomeAssistantSource>({
cache: haSourcesCache,
endpointPrefix: '/home-assistant/sources',
reload: async () => {
if (typeof (window as any).loadIntegrations === 'function') {
await (window as any).loadIntegrations();
}
},
typeLabelKey: 'device.icon.entity.ha_source',
typeLabelFallback: 'Home Assistant source',
cardSelectors: (id) => [
`[data-card-section="ha-sources"] [data-id="${CSS.escape(id)}"]`,
],
}));
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
// ── Modal ──
@@ -240,6 +257,7 @@ export function createHASourceCard(source: HomeAssistantSource) {
name: source.name,
metaHtml: escapeHtml(`${source.host}${isConnected ? ` · ${source.entity_count} entities` : ''}`),
leds,
...makeCardIconFields('ha_source', source.id, source),
menu: {
duplicateOnclick: `cloneHASource('${source.id}')`,
hideOnclick: `toggleCardHidden('ha-sources','${source.id}')`,
@@ -0,0 +1,636 @@
/**
* Icon picker modal choose a custom icon for an entity card.
*
* 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, outputTargetsCache } from '../core/state.ts';
import {
DEVICE_ICONS,
CATEGORIES,
iconsByCategory,
filterIcons,
getDeviceIconDef,
renderDeviceIconSvg,
type IconCategory,
type DeviceIconDef,
} from '../core/device-icons.ts';
const RECENT_KEY = 'ledgrab.icon-picker.recent';
const RECENT_MAX = 10;
// ────────────────────────────────────────────────────────────────
// Entity-type registry
// ────────────────────────────────────────────────────────────────
//
// Built-in types (device, target) are registered below. Other feature
// modules call ``registerIconEntityType()`` at import time to add their
// own — keeps icon-picker.ts decoupled from each feature's cache and
// reload pathway.
export type EntityType = string;
export interface EntityRecord {
id: string;
name: string;
icon: string;
icon_color: string;
}
export 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;
}
export 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 (path only — fetchWithAuth prepends ``/api/v1``). */
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" / "Picture source". */
typeLabel(id: string): string;
/** Optional CSS selector(s) to find the live card element so the
* picker preview can read its channel accent. Tried in order. */
cardSelectors?: (id: string) => string[];
/** Optional extra fields merged into the PUT body used for
* discriminator-keyed routes (e.g. output-targets needs
* ``target_type`` for the ``OutputTargetUpdate`` discriminated
* union). Receives the entity id; adapter does its own lookup. */
bodyExtras?: (id: string) => Record<string, unknown>;
}
const _adapters: Map<EntityType, EntityTypeAdapter> = new Map();
/** Public registration entry used by feature modules. Registering an
* unknown type is idempotent re-registering replaces the adapter. */
export function registerIconEntityType(type: EntityType, adapter: EntityTypeAdapter): void {
_adapters.set(type, adapter);
}
/** Helper for the common case: a single cache + a single PUT endpoint
* that accepts ``{icon, icon_color}`` directly. Reduces per-feature
* registration to ~6 lines. */
export function makeSimpleIconAdapter<T extends { id: string; name?: string; icon?: string; icon_color?: string }>(opts: {
cache: { data: T[] | null; invalidate: () => void };
/** PUT path prefix (no ``/api/v1``). Final URL = ``${endpointPrefix}/${id}``. */
endpointPrefix: string;
/** Async refresh hook. Cache is invalidated automatically. */
reload: () => Promise<void> | void;
typeLabelKey: string;
typeLabelFallback: string;
cardSelectors?: (id: string) => string[];
bodyExtras?: (rec: T) => Record<string, unknown>;
}): EntityTypeAdapter {
const _find = (id: string): T | undefined =>
(opts.cache.data ?? []).find((x) => x.id === id);
return {
lookup: (id: string) => {
const rec = _find(id);
if (!rec) return null;
return {
id: rec.id,
name: (rec.name as string | undefined) ?? rec.id,
icon: (rec.icon as string | undefined) ?? '',
icon_color: (rec.icon_color as string | undefined) ?? '',
};
},
endpoint: (id: string) => `${opts.endpointPrefix}/${id}`,
reload: async () => {
opts.cache.invalidate();
await opts.reload();
},
inheritedFrom: () => null,
typeLabel: () => t(opts.typeLabelKey) || opts.typeLabelFallback,
cardSelectors: opts.cardSelectors,
bodyExtras: opts.bodyExtras
? (id: string) => {
const rec = _find(id);
return rec ? opts.bodyExtras!(rec) : {};
}
: undefined,
};
}
// ── Built-in adapters: device + target ──────────────────────────
registerIconEntityType('device', {
lookup: (id: string) => {
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) ?? '',
};
},
endpoint: (id) => `/devices/${id}`,
reload: async () => {
devicesCache.invalidate();
await window.loadDevices?.();
},
inheritedFrom: () => null,
typeLabel: () => t('device.icon.entity.device') || 'Device',
cardSelectors: (id) => [`[data-device-id="${CSS.escape(id)}"]`],
});
registerIconEntityType('target', {
lookup: (id: string) => {
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) ?? '',
};
},
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: (id: string) => {
const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id);
if ((tgt as any)?.target_type === 'ha_light') {
return t('device.icon.entity.ha_light_target') || 'HA light target';
}
return t('device.icon.entity.target') || 'LED target';
},
cardSelectors: (id) => [
`[data-target-id="${CSS.escape(id)}"]`,
`[data-ha-target-id="${CSS.escape(id)}"]`,
],
bodyExtras: (id: string) => {
const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id);
return { target_type: ((tgt as any)?.target_type as string | undefined) ?? 'led' };
},
});
// ────────────────────────────────────────────────────────────────
// Picker state
// ────────────────────────────────────────────────────────────────
interface PickerContext {
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;
let _selectedIconId: string = '';
let _selectedColor: string = '';
let _activeCategory: IconCategory | 'all' = 'all';
let _query: string = '';
let _modalInstance: Modal | null = null;
// ────────────────────────────────────────────────────────────────
// Recent-icons persistence
// ────────────────────────────────────────────────────────────────
function _readRecent(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
} catch {
return [];
}
}
function _pushRecent(iconId: string): void {
if (!iconId) return;
try {
const list = _readRecent().filter((x) => x !== iconId);
list.unshift(iconId);
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
} catch {
/* ignore quota / disabled storage */
}
}
// ────────────────────────────────────────────────────────────────
// Public entry points
// ────────────────────────────────────────────────────────────────
/** 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.get(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.
// Adapters provide the candidate selectors; we try them in order
// and fall back to the global accent when no card is found.
const selectors = adapter.cardSelectors?.(entityId) ?? [];
let card: HTMLElement | null = null;
for (const sel of selectors) {
card = document.querySelector(sel) as HTMLElement | null;
if (card) break;
}
const channelColor = card
? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel()
: _fallbackChannel();
_ctx = {
entityType,
entityId,
entityName: rec?.name ?? entityId,
initialIconId,
initialColor,
channelColor,
inherited,
};
_selectedIconId = initialIconId;
_selectedColor = initialColor;
_activeCategory = 'all';
_query = '';
if (!_modalInstance) {
_modalInstance = new Modal('icon-picker-modal');
}
_renderModal();
_modalInstance.open();
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();
_ctx = null;
}
// ────────────────────────────────────────────────────────────────
// Rendering
// ────────────────────────────────────────────────────────────────
function _fallbackChannel(): string {
const root = getComputedStyle(document.documentElement);
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;
const gridEl = document.getElementById('icon-picker-grid') as HTMLElement | null;
const searchEl = document.getElementById('icon-picker-search') as HTMLInputElement | null;
const removeBtn = document.getElementById('icon-picker-remove') as HTMLButtonElement | null;
if (!previewEl || !tabsEl || !gridEl) return;
const display = _resolveDisplayIcon();
const effectiveColor = display.color;
previewEl.style.setProperty('--ch', effectiveColor);
previewEl.style.color = effectiveColor;
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>`;
// Header — entity type + name, plus inherited hint when applicable.
const adapter = _adapters.get(_ctx.entityType)!;
if (eyebrowEl) {
eyebrowEl.textContent = adapter.typeLabel(_ctx.entityId);
}
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
if (swatchEl) {
swatchEl.style.background = effectiveColor;
swatchEl.style.borderColor = effectiveColor;
}
if (searchEl && document.activeElement !== searchEl) {
searchEl.value = _query;
}
tabsEl.innerHTML = _renderTabsHtml();
if (recentEl) {
const recent = _readRecent();
if (recent.length === 0) {
recentEl.style.display = 'none';
} else {
recentEl.style.display = '';
recentEl.querySelector('.icon-picker-recent__strip')!.innerHTML = recent
.map((id) => _iconTileHtml(getDeviceIconDef(id)))
.filter(Boolean)
.join('');
}
}
gridEl.innerHTML = _renderGridHtml();
// Remove button — meaning depends on whether the entity has an own icon
// and whether an inherited fallback exists.
if (removeBtn) {
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;
}
}
function _renderTabsHtml(): string {
const total = DEVICE_ICONS.length;
const tabs: string[] = [];
const allCls = _activeCategory === 'all' ? 'icon-picker-tab is-active' : 'icon-picker-tab';
tabs.push(`<button type="button" class="${allCls}" data-cat="all">${escapeHtml(t('device.icon.cat.all') || 'All')} <span class="count">${total}</span></button>`);
for (const cat of CATEGORIES) {
const count = DEVICE_ICONS.filter((d) => d.category === cat.id).length;
const cls = _activeCategory === cat.id ? 'icon-picker-tab is-active' : 'icon-picker-tab';
const label = t(cat.i18n) || cat.label;
tabs.push(`<button type="button" class="${cls}" data-cat="${cat.id}">${escapeHtml(label)} <span class="count">${count}</span></button>`);
}
return tabs.join('');
}
function _renderGridHtml(): string {
const filtered = _query ? filterIcons(_query) : DEVICE_ICONS;
const inCat = _activeCategory === 'all'
? filtered
: filtered.filter((d) => d.category === _activeCategory);
if (inCat.length === 0) {
return `<div class="icon-picker-empty">${escapeHtml(t('device.icon.empty') || 'No icons match.')}</div>`;
}
if (_query || _activeCategory !== 'all') {
return `<div class="icon-picker-grid">${inCat.map(_iconTileHtml).join('')}</div>`;
}
const groups = iconsByCategory();
return groups
.filter((g) => g.items.length > 0)
.map((g) => {
const label = t(g.i18n) || g.label;
return `<div class="icon-picker-cat">${escapeHtml(label)}</div>
<div class="icon-picker-grid">${g.items.map(_iconTileHtml).join('')}</div>`;
})
.join('');
}
function _iconTileHtml(def: DeviceIconDef | null): string {
if (!def) return '';
const selected = def.id === _selectedIconId ? ' is-selected' : '';
const labelKey = `device.icon.${def.id}`;
const label = t(labelKey) !== labelKey ? t(labelKey) : def.label;
return `<button type="button" class="icon-tile${selected}" data-icon-id="${def.id}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${renderDeviceIconSvg(def.id, { size: 20 })}</button>`;
}
// ────────────────────────────────────────────────────────────────
// Apply / remove
// ────────────────────────────────────────────────────────────────
async function _applyChange(nextIconId: string, nextColor: string): Promise<void> {
if (!_ctx) return;
const { entityType, entityId, initialIconId, initialColor } = _ctx;
if (nextIconId === initialIconId && nextColor === initialColor) {
closeIconPicker();
return;
}
const adapter = _adapters.get(entityType)!;
try {
const body: Record<string, unknown> = { icon: nextIconId, icon_color: nextColor };
// Discriminated routes (e.g. output-targets) need extra fields
// — adapter declares them via ``bodyExtras``.
if (adapter.bodyExtras) {
Object.assign(body, adapter.bodyExtras(entityId));
}
const resp = await fetchWithAuth(adapter.endpoint(entityId), {
method: 'PUT',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast((err && (err as any).detail) || t('device.icon.error.save_failed'), 'error');
return;
}
if (nextIconId) _pushRecent(nextIconId);
showToast(t('device.icon.saved') || 'Icon saved', 'success');
await adapter.reload();
closeIconPicker();
} catch (error: any) {
if (error?.isAuth) return;
showToast(t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
}
}
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 {
_selectedIconId = iconId;
_renderModal();
}
function _setCategory(cat: IconCategory | 'all'): void {
_activeCategory = cat;
_renderModal();
}
function _setQuery(q: string): void {
_query = q;
if (q && _activeCategory !== 'all') _activeCategory = 'all';
_renderModal();
}
function _toggleColorOverride(): void {
if (!_ctx) return;
if (_selectedColor) {
_selectedColor = '';
} else {
_selectedColor = _ctx.channelColor;
}
_renderModal();
}
// ────────────────────────────────────────────────────────────────
// Modal-internal event wiring
// ────────────────────────────────────────────────────────────────
let _wired = false;
function _wireEvents(): void {
if (_wired) return;
_wired = true;
const root = document.getElementById('icon-picker-modal');
if (!root) return;
root.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const tile = target.closest('.icon-tile') as HTMLElement | null;
if (tile && tile.dataset.iconId) {
_selectIcon(tile.dataset.iconId);
return;
}
const tab = target.closest('.icon-picker-tab') as HTMLElement | null;
if (tab && tab.dataset.cat) {
_setCategory(tab.dataset.cat as IconCategory | 'all');
return;
}
if (target.closest('#icon-picker-color-toggle')) {
_toggleColorOverride();
return;
}
if (target.closest('#icon-picker-apply')) {
_applyCurrentSelection();
return;
}
if (target.closest('#icon-picker-cancel') || target.closest('.icon-picker-close')) {
closeIconPicker();
return;
}
if (target.closest('#icon-picker-remove')) {
_removeOwnIcon();
return;
}
});
const search = document.getElementById('icon-picker-search') as HTMLInputElement | null;
if (search) {
search.addEventListener('input', () => _setQuery(search.value));
}
root.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'BUTTON') {
e.preventDefault();
_applyCurrentSelection();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _wireEvents, { once: true });
} else {
_wireEvents();
}
// ────────────────────────────────────────────────────────────────
// 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 raw = el.getAttribute('data-icon-picker-trigger') || '';
if (!raw) return;
const [typeOrId, id] = raw.includes(':') ? raw.split(':', 2) : ['device', raw];
if (!id) return;
if (!_adapters.has(typeOrId)) return;
e.stopPropagation();
openIconPicker(typeOrId, id);
}
document.addEventListener('click', _onDocumentClick);
@@ -12,8 +12,25 @@ import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import type { MQTTSource } from '../types.ts';
registerIconEntityType('mqtt_source', makeSimpleIconAdapter<MQTTSource>({
cache: mqttSourcesCache,
endpointPrefix: '/mqtt/sources',
reload: async () => {
if (typeof (window as any).loadIntegrations === 'function') {
await (window as any).loadIntegrations();
}
},
typeLabelKey: 'device.icon.entity.mqtt_source',
typeLabelFallback: 'MQTT source',
cardSelectors: (id) => [
`[data-card-section="mqtt-sources"] [data-id="${CSS.escape(id)}"]`,
],
}));
const ICON_MQTT = `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`;
// ── Modal ──
@@ -250,6 +267,7 @@ export function createMQTTSourceCard(source: MQTTSource) {
name: source.name,
metaHtml: escapeHtml(`${broker} · ${source.base_topic}`),
leds,
...makeCardIconFields('mqtt_source', source.id, source),
menu: {
duplicateOnclick: `cloneMQTTSource('${source.id}')`,
hideOnclick: `toggleCardHidden('mqtt-sources','${source.id}')`,
@@ -11,15 +11,32 @@ import { CardSection } from '../core/card-sections.ts';
import {
ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_TRASH, ICON_LINK,
} from '../core/icons.ts';
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.ts';
import { renderDeviceIcon, renderDeviceIconSvg } from '../core/device-icons.ts';
import { scenePresetsCache, outputTargetsCache, automationsCacheObj, devicesCache } from '../core/state.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { wrapCard, cardColorStyle } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import type { ScenePreset } from '../types.ts';
registerIconEntityType('scene_preset', makeSimpleIconAdapter<ScenePreset>({
cache: scenePresetsCache,
endpointPrefix: '/scene-presets',
reload: async () => {
scenePresetsCache.invalidate();
if (typeof (window as any).loadAutomations === 'function') {
await (window as any).loadAutomations();
}
},
typeLabelKey: 'device.icon.entity.scene_preset',
typeLabelFallback: 'Scene preset',
cardSelectors: (id) => [`[data-scene-id="${CSS.escape(id)}"]`],
}));
let _editingId: string | null = null;
let _allTargets: any[] = []; // fetched on capture open
let _sceneTagsInput: TagInput | null = null;
@@ -116,6 +133,7 @@ export function createSceneCard(preset: ScenePreset) {
name: preset.name,
metaHtml,
leds,
...makeCardIconFields('scene_preset', preset.id, preset),
menu: {
duplicateOnclick: `cloneScenePreset('${preset.id}')`,
hideOnclick: `toggleCardHidden('scenes','${preset.id}')`,
@@ -184,8 +202,18 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
const activateLabel = t('scenes.activate') || 'Activate';
const pStyle = cardColorStyle(preset.id);
const iconId = preset.icon || '';
const iconColor = preset.icon_color || '';
const iconStyle = iconColor
? ` style="--ch:${escapeHtml(iconColor)};color:${escapeHtml(iconColor)}"`
: '';
const iconPlate = iconId
? `<div class="mod-icon"${iconStyle}>${renderDeviceIconSvg(iconId, { size: 24 })}</div>`
: '';
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" data-action="navigate-scene" data-id="${preset.id}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="mod-head">
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">SCN \u00b7 ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(preset.name)}</span></div>
@@ -361,26 +389,38 @@ function _refreshTargetSelect(): void {
}
}
function _addTargetToList(targetId: string, targetName: string): void {
function _addTargetToList(targetId: string, targetName: string, iconHtml?: string): void {
const list = document.getElementById('scene-target-list');
if (!list) return;
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
item.innerHTML = `<span>${iconHtml || ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
function _resolveTargetIcon(tgt: any, deviceMap: Record<string, any>): string {
let icon = renderDeviceIcon(tgt.icon);
if (!icon && tgt.target_type === 'led' && tgt.device_id) {
icon = renderDeviceIcon(deviceMap[tgt.device_id]?.icon);
}
return icon;
}
export async function addSceneTarget(): Promise<void> {
const added = _getAddedTargetIds();
const available = _allTargets.filter(t => !added.has(t.id));
if (available.length === 0) return;
const devices = await devicesCache.fetch().catch((): any[] => []);
const deviceMap: Record<string, any> = {};
for (const d of devices) deviceMap[d.id] = d;
const items = available.map(t => ({
value: t.id,
label: t.name,
icon: ICON_TARGET,
icon: _resolveTargetIcon(t, deviceMap) || ICON_TARGET,
}));
const picked = await EntityPalette.pick({
@@ -391,7 +431,7 @@ export async function addSceneTarget(): Promise<void> {
const tgt = _allTargets.find(t => t.id === picked);
if (tgt) {
_addTargetToList(tgt.id, tgt.name);
_addTargetToList(tgt.id, tgt.name, _resolveTargetIcon(tgt, deviceMap));
_autoGenerateScenePresetName();
}
}
@@ -72,6 +72,81 @@ import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { FilterListManager } from '../core/filter-list.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
// ── Icon-picker adapter registrations for streams-tab card types ──
const _reloadStreams = async () => {
if (typeof (window as any).loadPictureSources === 'function') {
await (window as any).loadPictureSources();
}
};
registerIconEntityType('picture_source', makeSimpleIconAdapter<any>({
cache: streamsCache,
endpointPrefix: '/picture-sources',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.picture_source',
typeLabelFallback: 'Picture source',
cardSelectors: (id) => [`[data-stream-id="${CSS.escape(id)}"]`],
bodyExtras: (rec) => ({ stream_type: (rec as any)?.stream_type ?? 'raw' }),
}));
registerIconEntityType('capture_template', makeSimpleIconAdapter<any>({
cache: captureTemplatesCache,
endpointPrefix: '/capture-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.capture_template',
typeLabelFallback: 'Capture template',
cardSelectors: (id) => [`[data-card-section="raw-templates"] [data-template-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('pp_template', makeSimpleIconAdapter<any>({
cache: ppTemplatesCache,
endpointPrefix: '/postprocessing-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.pp_template',
typeLabelFallback: 'Post-processing template',
cardSelectors: (id) => [`[data-pp-template-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('cspt', makeSimpleIconAdapter<any>({
cache: csptCache,
endpointPrefix: '/color-strip-processing-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.cspt',
typeLabelFallback: 'Color-strip processing template',
cardSelectors: (id) => [`[data-cspt-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('audio_source', makeSimpleIconAdapter<any>({
cache: audioSourcesCache,
endpointPrefix: '/audio-sources',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.audio_source',
typeLabelFallback: 'Audio source',
cardSelectors: (id) => [`[data-card-section="audio-sources"] [data-id="${CSS.escape(id)}"]`],
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'capture' }),
}));
registerIconEntityType('audio_template', makeSimpleIconAdapter<any>({
cache: audioTemplatesCache,
endpointPrefix: '/audio-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.audio_template',
typeLabelFallback: 'Audio template',
cardSelectors: (id) => [`[data-audio-template-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('gradient', makeSimpleIconAdapter<any>({
cache: gradientsCache,
endpointPrefix: '/gradients',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.gradient',
typeLabelFallback: 'Gradient',
cardSelectors: (id) => [`[data-card-section="gradients"] [data-id="${CSS.escape(id)}"]`],
}));
// ── TagInput instances for modals ──
let _streamTagsInput: TagInput | null = null;
@@ -454,6 +529,7 @@ function renderPictureSourcesList(streams: any) {
name: stream.name,
metaHtml: details.metaHtml,
leds: ['off'],
...makeCardIconFields('picture_source', stream.id, stream),
menu: {
duplicateOnclick: `cloneStream('${stream.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${stream.id}')`,
@@ -510,6 +586,7 @@ function renderPictureSourcesList(streams: any) {
name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'],
...makeCardIconFields('capture_template', template.id, template),
menu: {
duplicateOnclick: `cloneCaptureTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('raw-templates','${template.id}')`,
@@ -556,6 +633,7 @@ function renderPictureSourcesList(streams: any) {
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`),
leds: ['off'],
...makeCardIconFields('pp_template', tmpl.id, tmpl),
menu: {
duplicateOnclick: `clonePPTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`,
@@ -600,6 +678,7 @@ function renderPictureSourcesList(streams: any) {
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`),
leds: ['off'],
...makeCardIconFields('cspt', tmpl.id, tmpl),
menu: {
duplicateOnclick: `cloneCSPT('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`,
@@ -795,6 +874,7 @@ function renderPictureSourcesList(streams: any) {
name: src.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
...makeCardIconFields('audio_source', src.id, src),
menu: {
duplicateOnclick: `cloneAudioSource('${src.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`,
@@ -850,6 +930,7 @@ function renderPictureSourcesList(streams: any) {
name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'],
...makeCardIconFields('audio_template', template.id, template),
menu: {
duplicateOnclick: `cloneAudioTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('audio-templates','${template.id}')`,
@@ -896,6 +977,7 @@ function renderPictureSourcesList(streams: any) {
name: g.name,
metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`),
leds: ['off'],
...(g.is_builtin ? {} : makeCardIconFields('gradient', g.id, g)),
menu: {
duplicateOnclick: `cloneGradient('${g.id}')`,
hideOnclick: `toggleCardHidden('gradients','${g.id}')`,
@@ -11,9 +11,27 @@ import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../co
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import { loadPictureSources } from './streams.ts';
import type { SyncClock } from '../types.ts';
registerIconEntityType('sync_clock', makeSimpleIconAdapter<SyncClock>({
cache: syncClocksCache,
endpointPrefix: '/sync-clocks',
reload: async () => {
syncClocksCache.invalidate();
if (typeof (window as any).loadIntegrations === 'function') {
await (window as any).loadIntegrations();
}
},
typeLabelKey: 'device.icon.entity.sync_clock',
typeLabelFallback: 'Sync clock',
cardSelectors: (id) => [
`[data-card-section="sync-clocks"] [data-id="${CSS.escape(id)}"]`,
],
}));
// ── Auto-name ──
let _scNameManuallyEdited = false;
@@ -245,6 +263,7 @@ export function createSyncClockCard(clock: SyncClock) {
name: clock.name,
metaHtml: escapeHtml(`${statusLabel} · ${clock.speed}x`),
leds,
...makeCardIconFields('sync_clock', clock.id, clock),
menu: {
duplicateOnclick: `cloneSyncClock('${clock.id}')`,
hideOnclick: `toggleCardHidden('sync-clocks','${clock.id}')`,
@@ -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 { renderDeviceIcon, 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';
@@ -279,7 +280,7 @@ function _ensureTargetEntitySelects() {
getItems: () => _targetEditorDevices.map(d => ({
value: d.id,
label: d.name,
icon: getDeviceTypeIcon(d.device_type),
icon: renderDeviceIcon((d as any).icon) || getDeviceTypeIcon(d.device_type),
desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''),
})),
placeholder: t('palette.search'),
@@ -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}')`,
@@ -6,7 +6,7 @@ import { fetchWithAuth } from '../core/api.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
import { IconSelect } from '../core/icon-select.ts';
import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
import { ICON_SPARKLES, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
// ─── State ──────────────────────────────────────────────────
@@ -63,7 +63,7 @@ function _setVersionBadgeUpdate(hasUpdate: boolean): void {
}
}
function switchSettingsTabToUpdate(): void {
export function switchSettingsTabToUpdate(): void {
if (typeof (window as any).openSettingsModal === 'function') {
(window as any).openSettingsModal();
}
@@ -89,6 +89,7 @@ function _showBanner(status: UpdateStatus): void {
const versionLabel = release.prerelease
? `${release.version} (${t('update.prerelease')})`
: release.version;
const versionChip = `<span class="update-banner-version">${versionLabel}</span>`;
let actions = '';
@@ -99,9 +100,9 @@ function _showBanner(status: UpdateStatus): void {
</button>`;
}
actions += `<a href="${status.releases_url}" target="_blank" rel="noopener" class="btn btn-icon update-banner-action" title="${t('update.view_release')}">
${ICON_EXTERNAL_LINK}
</a>`;
actions += `<button class="btn btn-icon update-banner-action" onclick="switchSettingsTabToUpdate()" title="${t('update.view_release')}">
${ICON_SPARKLES}
</button>`;
actions += `<button class="btn btn-icon update-banner-action" onclick="dismissUpdate()" title="${t('update.dismiss')}">
${ICON_X}
@@ -109,9 +110,9 @@ function _showBanner(status: UpdateStatus): void {
banner.innerHTML = `
<span class="update-banner-text">
${t('update.available').replace('{version}', versionLabel)}
${t('update.available').replace('{version}', versionChip)}
</span>
${actions}
<span class="update-banner-actions">${actions}</span>
`;
banner.style.display = 'flex';
}
@@ -30,8 +30,27 @@ import {
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
registerIconEntityType('value_source', makeSimpleIconAdapter<any>({
cache: valueSourcesCache,
endpointPrefix: '/value-sources',
reload: async () => {
valueSourcesCache.invalidate();
if (typeof (window as any).loadIntegrations === 'function') {
await (window as any).loadIntegrations();
}
},
typeLabelKey: 'device.icon.entity.value_source',
typeLabelFallback: 'Value source',
cardSelectors: (id) => [
`[data-card-section="value-sources"] [data-id="${CSS.escape(id)}"]`,
],
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'static' }),
}));
import type { IconSelectItem } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
@@ -1398,6 +1417,7 @@ export function createValueSourceCard(src: ValueSource) {
name: src.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
...makeCardIconFields('value_source', src.id, src),
menu: {
duplicateOnclick: `cloneValueSource('${src.id}')`,
hideOnclick: `toggleCardHidden('value-sources','${src.id}')`,
@@ -13,8 +13,25 @@ import { IconSelect } from '../core/icon-select.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import type { WeatherSource } from '../types.ts';
registerIconEntityType('weather_source', makeSimpleIconAdapter<WeatherSource>({
cache: weatherSourcesCache,
endpointPrefix: '/weather-sources',
reload: async () => {
if (typeof (window as any).loadIntegrations === 'function') {
await (window as any).loadIntegrations();
}
},
typeLabelKey: 'device.icon.entity.weather_source',
typeLabelFallback: 'Weather source',
cardSelectors: (id) => [
`[data-card-section="weather-sources"] [data-id="${CSS.escape(id)}"]`,
],
}));
const ICON_WEATHER = `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -282,6 +299,7 @@ export function createWeatherSourceCard(source: WeatherSource) {
name: source.name,
metaHtml: escapeHtml(`${providerLabel} · ${coords}`),
leds: ['on'],
...makeCardIconFields('weather_source', source.id, source),
menu: {
duplicateOnclick: `cloneWeatherSource('${source.id}')`,
hideOnclick: `toggleCardHidden('weather-sources','${source.id}')`,
+52
View File
@@ -79,6 +79,11 @@ export interface Device {
default_css_processing_template_id: string;
group_device_ids: string[];
group_mode: string;
/** Optional id from the curated icon library (e.g. 'mouse', 'motherboard').
* Empty/missing no plate is rendered, head reverts to badge-only layout. */
icon?: string;
/** Optional CSS color override for the icon. Empty/missing inherits --ch. */
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -100,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;
}
@@ -117,10 +129,16 @@ export interface LedOutputTarget extends OutputTargetBase {
protocol: string;
}
export type HALightSourceKind = 'css' | 'color_vs';
export interface HALightOutputTarget extends OutputTargetBase {
target_type: 'ha_light';
ha_source_id: string;
/** Which colour source feeds the lights: a CSS (`'css'`) or a colour-returning value source (`'color_vs'`). */
source_kind: HALightSourceKind;
color_strip_source_id: string;
/** Used when `source_kind === 'color_vs'`. References a value source whose `return_type === 'color'`. */
color_value_source_id?: string;
brightness?: BindableFloat;
ha_light_mappings?: HALightMapping[];
update_rate?: BindableFloat;
@@ -210,6 +228,8 @@ export interface ColorStripSource {
tags: string[];
overlay_active: boolean;
clock_id?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
@@ -310,6 +330,8 @@ export interface PatternTemplate {
rectangles: KeyColorRectangle[];
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -340,6 +362,8 @@ interface ValueSourceBase {
return_type: 'float' | 'color';
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -500,6 +524,8 @@ interface AudioSourceBase {
source_type: AudioSourceType;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -531,6 +557,8 @@ interface PictureSourceBase {
stream_type: PictureSourceType;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -590,6 +618,8 @@ export interface ScenePreset {
targets: TargetSnapshot[];
order: number;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -604,6 +634,8 @@ export interface SyncClock {
tags: string[];
is_running: boolean;
elapsed_time: number;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -618,6 +650,8 @@ export interface WeatherSource {
update_interval: number;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -639,6 +673,8 @@ export interface HomeAssistantSource {
entity_count: number;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -676,6 +712,8 @@ export interface MQTTSource {
connected: boolean;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -710,6 +748,8 @@ export interface Asset {
description?: string;
tags: string[];
prebuilt: boolean;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -754,6 +794,8 @@ export interface Automation {
is_active: boolean;
last_activated_at?: string;
last_deactivated_at?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -772,6 +814,8 @@ export interface CaptureTemplate {
engine_config: Record<string, any>;
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -782,6 +826,8 @@ export interface PostprocessingTemplate {
filters: FilterInstance[];
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -792,6 +838,8 @@ export interface ColorStripProcessingTemplate {
filters: FilterInstance[];
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -803,6 +851,8 @@ export interface AudioTemplate {
engine_config: Record<string, any>;
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
@@ -922,6 +972,8 @@ export interface GameIntegration {
enabled: boolean;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
+66
View File
@@ -560,6 +560,53 @@
"common.none_no_input": "None (no input source)",
"common.none_own_speed": "None (no sync)",
"common.undo": "Undo",
"common.cancel": "Cancel",
"common.apply": "Apply",
"device.icon.eyebrow": "Card icon",
"device.icon.title": "Choose an icon",
"device.icon.for": "for",
"device.icon.choose": "Choose icon…",
"device.icon.change": "Change icon…",
"device.icon.remove": "Remove icon",
"device.icon.search.placeholder": "Search icons…",
"device.icon.color_toggle": "Color",
"device.icon.recent": "Recent",
"device.icon.empty": "No icons match.",
"device.icon.hint": "↵ Apply · Esc Cancel",
"device.icon.saved": "Icon saved",
"device.icon.error.save_failed": "Failed to save icon",
"device.icon.cat.all": "All",
"device.icon.cat.hardware": "Hardware",
"device.icon.cat.lighting": "Lighting",
"device.icon.cat.rooms": "Rooms",
"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.entity.ha_light_target": "HA light target",
"device.icon.entity.picture_source": "Picture source",
"device.icon.entity.audio_source": "Audio source",
"device.icon.entity.weather_source": "Weather source",
"device.icon.entity.value_source": "Value source",
"device.icon.entity.mqtt_source": "MQTT source",
"device.icon.entity.ha_source": "Home Assistant source",
"device.icon.entity.automation": "Automation",
"device.icon.entity.scene_preset": "Scene preset",
"device.icon.entity.sync_clock": "Sync clock",
"device.icon.entity.game_integration": "Game integration",
"device.icon.entity.audio_processing_template": "Audio processing template",
"device.icon.entity.pattern_template": "Pattern template",
"device.icon.entity.capture_template": "Capture template",
"device.icon.entity.pp_template": "Post-processing template",
"device.icon.entity.cspt": "Color-strip processing template",
"device.icon.entity.audio_template": "Audio template",
"device.icon.entity.gradient": "Gradient",
"device.icon.entity.color_strip_source": "Color strip",
"device.icon.entity.asset": "Asset",
"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",
@@ -922,6 +969,11 @@
"dashboard.customize.density.comfortable": "Comfortable",
"dashboard.customize.density.compact": "Compact",
"dashboard.customize.density.dense": "Dense",
"card_mode.tooltip": "Card size",
"card_mode.comfortable": "Comfortable",
"card_mode.compact": "Compact",
"card_mode.dense": "Dense",
"card_mode.row": "List",
"dashboard.customize.collapse_default.on": "Start collapsed",
"dashboard.customize.collapse_default.off": "Start expanded",
"dashboard.customize.show": "Show",
@@ -2154,6 +2206,11 @@
"ha_light.name.placeholder": "Living Room Lights",
"ha_light.ha_source": "HA Connection:",
"ha_light.css_source": "Color Strip Source:",
"ha_light.color_source": "Color Source:",
"ha_light.color_source.hint": "Pick a Color Strip Source (per-light LED ranges) or a Color Value Source (one colour broadcast to every light).",
"ha_light.color_source.css": "Color strip",
"ha_light.color_source.color_vs": "Color value source",
"ha_light.mappings.color_vs_hint": "All listed lights will receive the same colour from the selected Color Value Source.",
"ha_light.update_rate": "Update Rate:",
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
"ha_light.transition": "Transition:",
@@ -2172,10 +2229,19 @@
"ha_light.description": "Description (optional):",
"ha_light.error.name_required": "Name is required",
"ha_light.error.ha_source_required": "HA connection is required",
"ha_light.error.color_source_required": "Color value source is required when broadcasting a single colour",
"ha_light.created": "HA light target created",
"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",
+66
View File
@@ -6,6 +6,20 @@
"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": "Вернуть состояние на момент запуска",
"ha_light.color_source": "Источник цвета:",
"ha_light.color_source.hint": "Выберите Источник полосы цвета (диапазоны LED для каждой лампы) или Источник значения цвета (один цвет на все лампы).",
"ha_light.color_source.css": "Полоса цвета",
"ha_light.color_source.color_vs": "Источник значения цвета",
"ha_light.mappings.color_vs_hint": "Все указанные лампы получат один и тот же цвет от выбранного Источника значения цвета.",
"ha_light.error.color_source_required": "Источник значения цвета обязателен в режиме одного цвета",
"app.version": "Версия:",
"app.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен",
@@ -564,6 +578,53 @@
"common.none_no_input": "Нет (без источника)",
"common.none_own_speed": "Нет (своя скорость)",
"common.undo": "Отменить",
"common.cancel": "Отмена",
"common.apply": "Применить",
"device.icon.eyebrow": "Иконка карточки",
"device.icon.title": "Выберите иконку",
"device.icon.for": "для",
"device.icon.choose": "Выбрать иконку…",
"device.icon.change": "Изменить иконку…",
"device.icon.remove": "Удалить иконку",
"device.icon.search.placeholder": "Поиск иконок…",
"device.icon.color_toggle": "Цвет",
"device.icon.recent": "Недавние",
"device.icon.empty": "Иконки не найдены.",
"device.icon.hint": "↵ Применить · Esc Отмена",
"device.icon.saved": "Иконка сохранена",
"device.icon.error.save_failed": "Не удалось сохранить иконку",
"device.icon.cat.all": "Все",
"device.icon.cat.hardware": "Оборудование",
"device.icon.cat.lighting": "Освещение",
"device.icon.cat.rooms": "Комнаты",
"device.icon.cat.media": "Медиа",
"device.icon.cat.signal": "Сигнал",
"device.icon.cat.ambience": "Атмосфера",
"device.icon.entity.device": "Устройство",
"device.icon.entity.target": "LED-цель",
"device.icon.entity.ha_light_target": "HA-светильник",
"device.icon.entity.picture_source": "Источник изображения",
"device.icon.entity.audio_source": "Источник аудио",
"device.icon.entity.weather_source": "Источник погоды",
"device.icon.entity.value_source": "Источник значения",
"device.icon.entity.mqtt_source": "Источник MQTT",
"device.icon.entity.ha_source": "Источник Home Assistant",
"device.icon.entity.automation": "Автоматизация",
"device.icon.entity.scene_preset": "Сцена",
"device.icon.entity.sync_clock": "Часы синхронизации",
"device.icon.entity.game_integration": "Игровая интеграция",
"device.icon.entity.audio_processing_template": "Шаблон обработки аудио",
"device.icon.entity.pattern_template": "Шаблон паттерна",
"device.icon.entity.capture_template": "Шаблон захвата",
"device.icon.entity.pp_template": "Шаблон постобработки",
"device.icon.entity.cspt": "Шаблон обработки полоски",
"device.icon.entity.audio_template": "Аудиошаблон",
"device.icon.entity.gradient": "Градиент",
"device.icon.entity.color_strip_source": "Цветная полоска",
"device.icon.entity.asset": "Ассет",
"device.icon.inherited_from": "Унаследовано от %s",
"device.icon.override_inherited": "Заменить унаследованную иконку…",
"device.icon.use_inherited": "Использовать унаследованную",
"validation.required": "Обязательное поле",
"bulk.processing": "Обработка…",
"api.error.timeout": "Превышено время ожидания — попробуйте снова",
@@ -903,6 +964,11 @@
"dashboard.customize.density.comfortable": "Просторно",
"dashboard.customize.density.compact": "Компактно",
"dashboard.customize.density.dense": "Плотно",
"card_mode.tooltip": "Размер карточек",
"card_mode.comfortable": "Просторно",
"card_mode.compact": "Компактно",
"card_mode.dense": "Плотно",
"card_mode.row": "Список",
"dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию",
"dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию",
"dashboard.customize.show": "Показать",
+66
View File
@@ -6,6 +6,20 @@
"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": "恢复到启动时捕获的状态",
"ha_light.color_source": "颜色源:",
"ha_light.color_source.hint": "选择颜色条源(每个灯具的 LED 范围)或颜色值源(一种颜色广播到所有灯具)。",
"ha_light.color_source.css": "颜色条",
"ha_light.color_source.color_vs": "颜色值源",
"ha_light.mappings.color_vs_hint": "所有列出的灯具都将接收来自所选颜色值源的相同颜色。",
"ha_light.error.color_source_required": "广播单一颜色时必须选择颜色值源",
"app.version": "版本:",
"app.api_docs": "API 文档",
"app.connection_lost": "服务器不可达",
@@ -564,6 +578,53 @@
"common.none_no_input": "无(无输入源)",
"common.none_own_speed": "无(使用自身速度)",
"common.undo": "撤销",
"common.cancel": "取消",
"common.apply": "应用",
"device.icon.eyebrow": "卡片图标",
"device.icon.title": "选择图标",
"device.icon.for": "用于",
"device.icon.choose": "选择图标…",
"device.icon.change": "更换图标…",
"device.icon.remove": "移除图标",
"device.icon.search.placeholder": "搜索图标…",
"device.icon.color_toggle": "颜色",
"device.icon.recent": "最近使用",
"device.icon.empty": "无匹配的图标。",
"device.icon.hint": "↵ 应用 · Esc 取消",
"device.icon.saved": "图标已保存",
"device.icon.error.save_failed": "保存图标失败",
"device.icon.cat.all": "全部",
"device.icon.cat.hardware": "硬件",
"device.icon.cat.lighting": "照明",
"device.icon.cat.rooms": "房间",
"device.icon.cat.media": "媒体",
"device.icon.cat.signal": "信号",
"device.icon.cat.ambience": "氛围",
"device.icon.entity.device": "设备",
"device.icon.entity.target": "LED 目标",
"device.icon.entity.ha_light_target": "HA 灯目标",
"device.icon.entity.picture_source": "画面源",
"device.icon.entity.audio_source": "音频源",
"device.icon.entity.weather_source": "天气源",
"device.icon.entity.value_source": "数值源",
"device.icon.entity.mqtt_source": "MQTT 源",
"device.icon.entity.ha_source": "Home Assistant 源",
"device.icon.entity.automation": "自动化",
"device.icon.entity.scene_preset": "场景预设",
"device.icon.entity.sync_clock": "同步时钟",
"device.icon.entity.game_integration": "游戏集成",
"device.icon.entity.audio_processing_template": "音频处理模板",
"device.icon.entity.pattern_template": "图案模板",
"device.icon.entity.capture_template": "捕获模板",
"device.icon.entity.pp_template": "后处理模板",
"device.icon.entity.cspt": "色带处理模板",
"device.icon.entity.audio_template": "音频模板",
"device.icon.entity.gradient": "渐变",
"device.icon.entity.color_strip_source": "色带",
"device.icon.entity.asset": "资源",
"device.icon.inherited_from": "继承自 %s",
"device.icon.override_inherited": "覆盖继承的图标…",
"device.icon.use_inherited": "使用继承的",
"validation.required": "此字段为必填项",
"bulk.processing": "处理中…",
"api.error.timeout": "请求超时 — 请重试",
@@ -903,6 +964,11 @@
"dashboard.customize.density.comfortable": "宽松",
"dashboard.customize.density.compact": "紧凑",
"dashboard.customize.density.dense": "密集",
"card_mode.tooltip": "卡片大小",
"card_mode.comfortable": "宽松",
"card_mode.compact": "紧凑",
"card_mode.dense": "密集",
"card_mode.row": "列表",
"dashboard.customize.collapse_default.on": "默认折叠",
"dashboard.customize.collapse_default.off": "默认展开",
"dashboard.customize.show": "显示",
+11 -1
View File
@@ -43,9 +43,12 @@ class Asset:
tags: List[str] = field(default_factory=list)
prebuilt: bool = False # True for shipped assets
deleted: bool = False # soft-delete for prebuilt assets
# Custom card icon (frontend display only)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict:
return {
d = {
"id": self.id,
"name": self.name,
"filename": self.filename,
@@ -60,6 +63,11 @@ class Asset:
"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
@staticmethod
def from_dict(data: dict) -> "Asset":
@@ -75,6 +83,8 @@ class Asset:
tags=data.get("tags", []),
prebuilt=bool(data.get("prebuilt", False)),
deleted=bool(data.get("deleted", False)),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
)

Some files were not shown because too many files have changed in this diff Show More