Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09792a9a05 | |||
| 75ca487be1 | |||
| e65dcb41f4 | |||
| 6a07a6b1a2 | |||
| 0f5850ef80 | |||
| a79f4bf73c | |||
| ced72fc864 | |||
| 49ddabbc36 | |||
| a026f0b349 |
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.1–10.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.1–10.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")
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>▶</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}')`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показать",
|
||||
|
||||
@@ -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": "显示",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user