feat(ui): customisable card icon for all entity types

Extends the icon-plate work from devices and output targets to every
remaining card type — 18 new entities, 20 in total. Users can now pick
a curated icon (with optional colour override) for any card on any tab,
and the picker reuses the same modal, recent-strip, search, and
category tabs introduced for the device picker.

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

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

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

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

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

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

Pending: manual smoke test on each card type — open picker, save, and
confirm the channel-color preview matches the live card.
This commit is contained in:
2026-05-09 16:19:20 +03:00
parent a79f4bf73c
commit 0f5850ef80
97 changed files with 1810 additions and 147 deletions
+90
View File
@@ -1,5 +1,95 @@
# LedGrab TODO # 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 ## Device Event Notifications
Notify the user when LED devices come online/go offline (configured targets), and when new Notify the user when LED devices come online/go offline (configured targets), and when new
+2
View File
@@ -142,6 +142,8 @@ async def update_asset(
name=body.name, name=body.name,
description=body.description, description=body.description,
tags=body.tags, tags=body.tags,
icon=body.icon,
icon_color=body.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}") 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, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, 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, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_processing_template", "created", template.id) fire_entity_event("audio_processing_template", "created", template.id)
return _apt_to_response(template) return _apt_to_response(template)
@@ -129,6 +133,8 @@ async def update_audio_processing_template(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_processing_template", "updated", template_id) fire_entity_event("audio_processing_template", "updated", template_id)
# Hot-update: rebuild filter pipelines for running streams using this template # Hot-update: rebuild filter pipelines for running streams using this template
@@ -46,6 +46,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
device_index=s.device_index, device_index=s.device_index,
is_loopback=s.is_loopback, is_loopback=s.is_loopback,
audio_template_id=s.audio_template_id, audio_template_id=s.audio_template_id,
@@ -57,6 +59,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_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_source_id=s.audio_source_id,
audio_processing_template_id=s.audio_processing_template_id, audio_processing_template_id=s.audio_processing_template_id,
), ),
@@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
tags=source.tags, tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_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), device_index=getattr(source, "device_index", -1),
is_loopback=getattr(source, "is_loopback", True), is_loopback=getattr(source, "is_loopback", True),
audio_template_id=getattr(source, "audio_template_id", None), audio_template_id=getattr(source, "audio_template_id", None),
@@ -53,6 +53,8 @@ async def list_audio_templates(
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
for t in templates for t in templates
] ]
@@ -81,6 +83,8 @@ async def create_audio_template(
engine_config=data.engine_config, engine_config=data.engine_config,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_template", "created", template.id) fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse( return AudioTemplateResponse(
@@ -92,6 +96,8 @@ async def create_audio_template(
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
icon=getattr(template, "icon", "") or "",
icon_color=getattr(template, "icon_color", "") or "",
) )
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -127,6 +133,8 @@ async def get_audio_template(
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, 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, engine_config=data.engine_config,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("audio_template", "updated", template_id) fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse( return AudioTemplateResponse(
@@ -161,6 +171,8 @@ async def update_audio_template(
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
) )
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(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_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"), last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags, tags=automation.tags,
icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at, created_at=automation.created_at,
updated_at=automation.updated_at, updated_at=automation.updated_at,
) )
@@ -191,6 +193,8 @@ async def create_automation(
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id, deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
if automation.enabled: if automation.enabled:
@@ -285,6 +289,8 @@ async def update_automation(
rules=rules, rules=rules,
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
if data.scene_preset_id is not None: if data.scene_preset_id is not None:
update_kwargs["scene_preset_id"] = data.scene_preset_id update_kwargs["scene_preset_id"] = data.scene_preset_id
@@ -43,6 +43,8 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, 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, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("cspt", "created", template.id) fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template) return _cspt_to_response(template)
@@ -141,6 +145,8 @@ async def update_cspt(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("cspt", "updated", template_id) fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template) return _cspt_to_response(template)
@@ -65,6 +65,8 @@ def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
tags=source.tags, tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
) )
@@ -158,6 +158,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
updated_at=config.updated_at, updated_at=config.updated_at,
description=config.description, description=config.description,
tags=config.tags, 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, event_mappings=mappings,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("game_integration", "created", config.id) fire_entity_event("game_integration", "created", config.id)
@@ -323,6 +327,8 @@ async def update_integration(
event_mappings=mappings, event_mappings=mappings,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("game_integration", "updated", integration_id) fire_entity_event("game_integration", "updated", integration_id)
@@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse:
tags=gradient.tags, tags=gradient.tags,
created_at=gradient.created_at, created_at=gradient.created_at,
updated_at=gradient.updated_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], stops=[s.model_dump() for s in data.stops],
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("gradient", "created", gradient.id) fire_entity_event("gradient", "created", gradient.id)
return _to_response(gradient) return _to_response(gradient)
@@ -103,6 +107,8 @@ async def update_gradient(
stops=stops, stops=stops,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("gradient", "updated", gradient_id) fire_entity_event("gradient", "updated", gradient_id)
return _to_response(gradient) return _to_response(gradient)
@@ -55,6 +55,8 @@ def _to_response(
entity_count=len(runtime.get_all_states()) if runtime else 0, entity_count=len(runtime.get_all_states()) if runtime else 0,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
token=token_field, token=token_field,
@@ -105,6 +107,8 @@ async def create_ha_source(
entity_filters=data.entity_filters, entity_filters=data.entity_filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -158,6 +162,8 @@ async def update_ha_source(
entity_filters=data.entity_filters, entity_filters=data.entity_filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found") raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
+6
View File
@@ -45,6 +45,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
connected=runtime.is_connected if runtime else False, connected=runtime.is_connected if runtime else False,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
) )
@@ -90,6 +92,8 @@ async def create_mqtt_source(
base_topic=data.base_topic, base_topic=data.base_topic,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -139,6 +143,8 @@ async def update_mqtt_source(
base_topic=data.base_topic, base_topic=data.base_topic,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found") raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
@@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, 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, rectangles=rectangles,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pattern_template", "created", template.id) fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
@@ -139,6 +143,8 @@ async def update_pattern_template(
rectangles=rectangles, rectangles=rectangles,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pattern_template", "updated", template_id) fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
@@ -65,6 +65,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
display_index=s.display_index, display_index=s.display_index,
capture_template_id=s.capture_template_id, capture_template_id=s.capture_template_id,
target_fps=s.target_fps, target_fps=s.target_fps,
@@ -76,6 +78,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_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, source_stream_id=s.source_stream_id,
postprocessing_template_id=s.postprocessing_template_id, postprocessing_template_id=s.postprocessing_template_id,
), ),
@@ -86,6 +90,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_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, image_asset_id=s.image_asset_id,
), ),
VideoCaptureSource: lambda s: VideoPictureSourceResponse( VideoCaptureSource: lambda s: VideoPictureSourceResponse(
@@ -95,6 +101,8 @@ _RESPONSE_MAP = {
tags=s.tags, tags=s.tags,
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_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, video_asset_id=s.video_asset_id,
loop=s.loop, loop=s.loop,
playback_speed=s.playback_speed, playback_speed=s.playback_speed,
@@ -49,6 +49,8 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=t.tags, 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, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pp_template", "created", template.id) fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
@@ -143,6 +147,8 @@ async def update_pp_template(
filters=filters, filters=filters,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("pp_template", "updated", template_id) fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
@@ -51,6 +51,8 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
], ],
order=preset.order, order=preset.order,
tags=preset.tags, tags=preset.tags,
icon=getattr(preset, "icon", "") or "",
icon_color=getattr(preset, "icon_color", "") or "",
created_at=preset.created_at, created_at=preset.created_at,
updated_at=preset.updated_at, updated_at=preset.updated_at,
) )
@@ -84,6 +86,8 @@ async def create_scene_preset(
targets=targets, targets=targets,
order=store.count(), order=store.count(),
tags=data.tags if data.tags is not None else [], tags=data.tags if data.tags is not None else [],
icon=data.icon or "",
icon_color=data.icon_color or "",
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -182,6 +186,8 @@ async def update_scene_preset(
order=data.order, order=data.order,
targets=new_targets, targets=new_targets,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(
@@ -38,6 +38,8 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
speed=rt.speed if rt else clock.speed, speed=rt.speed if rt else clock.speed,
description=clock.description, description=clock.description,
tags=clock.tags, tags=clock.tags,
icon=getattr(clock, "icon", "") or "",
icon_color=getattr(clock, "icon_color", "") or "",
is_running=rt.is_running if rt else True, is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0, elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at, created_at=clock.created_at,
@@ -75,6 +77,8 @@ async def create_sync_clock(
speed=data.speed, speed=data.speed,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
fire_entity_event("sync_clock", "created", clock.id) fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager) return _to_response(clock, manager)
@@ -120,6 +124,8 @@ async def update_sync_clock(
speed=data.speed, speed=data.speed,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
# Hot-update runtime speed # Hot-update runtime speed
if data.speed is not None: if data.speed is not None:
+23 -43
View File
@@ -45,6 +45,21 @@ logger = get_logger(__name__)
router = APIRouter() 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 ===== # ===== CAPTURE TEMPLATE ENDPOINTS =====
@@ -57,19 +72,7 @@ async def list_templates(
try: try:
templates = template_store.get_all_templates() templates = template_store.get_all_templates()
template_responses = [ template_responses = [_template_to_response(t) for t in templates]
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
]
return TemplateListResponse( return TemplateListResponse(
templates=template_responses, templates=template_responses,
@@ -100,19 +103,12 @@ async def create_template(
engine_config=template_data.engine_config, engine_config=template_data.engine_config,
description=template_data.description, description=template_data.description,
tags=template_data.tags, tags=template_data.tags,
icon=template_data.icon,
icon_color=template_data.icon_color,
) )
fire_entity_event("capture_template", "created", template.id) fire_entity_event("capture_template", "created", template.id)
return TemplateResponse( return _template_to_response(template)
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,
)
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -138,16 +134,7 @@ async def get_template(
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail=f"Template {template_id} not found") raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
return TemplateResponse( return _template_to_response(template)
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,
)
@router.put( @router.put(
@@ -168,19 +155,12 @@ async def update_template(
engine_config=update_data.engine_config, engine_config=update_data.engine_config,
description=update_data.description, description=update_data.description,
tags=update_data.tags, tags=update_data.tags,
icon=update_data.icon,
icon_color=update_data.icon_color,
) )
fire_entity_event("capture_template", "updated", template_id) fire_entity_event("capture_template", "updated", template_id)
return TemplateResponse( return _template_to_response(template)
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,
)
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -64,6 +64,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
value=s.value, value=s.value,
@@ -73,6 +75,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
waveform=s.waveform, waveform=s.waveform,
@@ -85,6 +89,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
audio_source_id=s.audio_source_id, audio_source_id=s.audio_source_id,
@@ -100,6 +106,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
speed=s.speed, speed=s.speed,
@@ -114,6 +122,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
color=list(s.color), color=list(s.color),
@@ -123,6 +133,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
colors=[list(c) for c in s.colors], colors=[list(c) for c in s.colors],
@@ -135,6 +147,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
schedule=s.schedule, schedule=s.schedule,
@@ -144,6 +158,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
ha_source_id=s.ha_source_id, ha_source_id=s.ha_source_id,
@@ -158,6 +174,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
value_source_id=s.value_source_id, value_source_id=s.value_source_id,
@@ -169,6 +187,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
color_strip_source_id=s.color_strip_source_id, color_strip_source_id=s.color_strip_source_id,
@@ -180,6 +200,8 @@ _RESPONSE_MAP = {
name=s.name, name=s.name,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
metric=s.metric, metric=s.metric,
@@ -204,6 +226,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name, name=source.name,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
picture_source_id=source.picture_source_id, picture_source_id=source.picture_source_id,
@@ -218,6 +242,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name, name=source.name,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
schedule=source.schedule, schedule=source.schedule,
@@ -233,6 +259,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name, name=source.name,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
value=getattr(source, "value", 1.0), value=getattr(source, "value", 1.0),
@@ -39,6 +39,8 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse:
update_interval=d["update_interval"], update_interval=d["update_interval"],
description=d.get("description"), description=d.get("description"),
tags=d.get("tags", []), tags=d.get("tags", []),
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
) )
@@ -79,6 +81,8 @@ async def create_weather_source(
update_interval=data.update_interval, update_interval=data.update_interval,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -125,6 +129,8 @@ async def update_weather_source(
update_interval=data.update_interval, update_interval=data.update_interval,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
) )
except EntityNotFoundError: except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found") raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
+20
View File
@@ -12,6 +12,16 @@ class AssetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name") 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") description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags") 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): class AssetResponse(BaseModel):
@@ -26,6 +36,16 @@ class AssetResponse(BaseModel):
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset") 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update 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) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class AudioProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class AudioProcessingTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None 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): class AudioProcessingTemplateResponse(BaseModel):
@@ -42,6 +62,16 @@ class AudioProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") 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): class AudioProcessingTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _AudioSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
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): class CaptureAudioSourceResponse(_AudioSourceResponseBase):
@@ -53,6 +63,16 @@ class _AudioSourceCreateBase(BaseModel):
name: str = Field(description="Source name", min_length=1, max_length=100) name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class CaptureAudioSourceCreate(_AudioSourceCreateBase):
@@ -87,6 +107,16 @@ class _AudioSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
@@ -16,6 +16,16 @@ class AudioTemplateCreate(BaseModel):
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class AudioTemplateUpdate(BaseModel):
@@ -26,6 +36,16 @@ class AudioTemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None 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): class AudioTemplateResponse(BaseModel):
@@ -39,6 +59,16 @@ class AudioTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") 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): class AudioTemplateListResponse(BaseModel):
@@ -67,6 +67,16 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class AutomationUpdate(BaseModel):
@@ -84,6 +94,16 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: Optional[List[str]] = None 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): class AutomationResponse(BaseModel):
@@ -108,6 +128,16 @@ class AutomationResponse(BaseModel):
last_deactivated_at: Optional[datetime] = Field( last_deactivated_at: Optional[datetime] = Field(
None, description="Last time this automation was deactivated" 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update 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) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class ColorStripProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class ColorStripProcessingTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None 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): class ColorStripProcessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class ColorStripProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") 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): class ColorStripProcessingTemplateListResponse(BaseModel):
@@ -95,6 +95,16 @@ class _CSSResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
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): class PictureCSSResponse(_CSSResponseBase):
@@ -266,6 +276,16 @@ class _CSSCreateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class PictureCSSCreate(_CSSCreateBase):
@@ -450,6 +470,16 @@ class _CSSUpdateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None 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): class PictureCSSUpdate(_CSSUpdateBase):
@@ -42,6 +42,16 @@ class GameIntegrationCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class GameIntegrationUpdate(BaseModel):
@@ -56,6 +66,16 @@ class GameIntegrationUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags") 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): class GameIntegrationResponse(BaseModel):
@@ -71,6 +91,16 @@ class GameIntegrationResponse(BaseModel):
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description") description: Optional[str] = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class GameIntegrationListResponse(BaseModel):
@@ -20,6 +20,16 @@ class GradientCreate(BaseModel):
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2) stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class GradientUpdate(BaseModel):
@@ -29,6 +39,16 @@ class GradientUpdate(BaseModel):
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2) stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class GradientResponse(BaseModel):
@@ -42,6 +62,16 @@ class GradientResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
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): class GradientListResponse(BaseModel):
@@ -18,6 +18,16 @@ class HomeAssistantSourceCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class HomeAssistantSourceUpdate(BaseModel):
@@ -30,6 +40,16 @@ class HomeAssistantSourceUpdate(BaseModel):
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns") entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class HomeAssistantSourceResponse(BaseModel):
@@ -44,6 +64,16 @@ class HomeAssistantSourceResponse(BaseModel):
entity_count: int = Field(default=0, description="Number of cached entities") entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
token: Optional[str] = Field( token: Optional[str] = Field(
+30
View File
@@ -18,6 +18,16 @@ class MQTTSourceCreate(BaseModel):
base_topic: str = Field(default="ledgrab", description="Base topic prefix") base_topic: str = Field(default="ledgrab", description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class MQTTSourceUpdate(BaseModel):
@@ -32,6 +42,16 @@ class MQTTSourceUpdate(BaseModel):
base_topic: Optional[str] = Field(None, description="Base topic prefix") base_topic: Optional[str] = Field(None, description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class MQTTSourceResponse(BaseModel):
@@ -48,6 +68,16 @@ class MQTTSourceResponse(BaseModel):
connected: bool = Field(default=False, description="Whether the broker connection is active") connected: bool = Field(default=False, description="Whether the broker connection is active")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -17,6 +17,16 @@ class PatternTemplateCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class PatternTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PatternTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None 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): class PatternTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PatternTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") 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): class PatternTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _PictureSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
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): class RawPictureSourceResponse(_PictureSourceResponseBase):
@@ -72,6 +82,16 @@ class _PictureSourceCreateBase(BaseModel):
name: str = Field(description="Stream name", min_length=1, max_length=100) name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500) description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class RawPictureSourceCreate(_PictureSourceCreateBase):
@@ -127,6 +147,16 @@ class _PictureSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) 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) description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None 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): class RawPictureSourceUpdate(_PictureSourceUpdateBase):
@@ -17,6 +17,16 @@ class PostprocessingTemplateCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class PostprocessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PostprocessingTemplateUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None 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): class PostprocessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PostprocessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") 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): class PostprocessingTemplateListResponse(BaseModel):
@@ -23,6 +23,16 @@ class ScenePresetCreate(BaseModel):
None, description="Target IDs to capture (all if omitted)" None, description="Target IDs to capture (all if omitted)"
) )
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class ScenePresetUpdate(BaseModel):
@@ -36,6 +46,16 @@ class ScenePresetUpdate(BaseModel):
description="Update target list: keep state for existing, capture fresh for new, drop removed", description="Update target list: keep state for existing, capture fresh for new, drop removed",
) )
tags: Optional[List[str]] = None 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): class ScenePresetResponse(BaseModel):
@@ -47,6 +67,16 @@ class ScenePresetResponse(BaseModel):
targets: List[TargetSnapshotSchema] targets: List[TargetSnapshotSchema]
order: int order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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 created_at: datetime
updated_at: datetime updated_at: datetime
@@ -13,6 +13,16 @@ class SyncClockCreate(BaseModel):
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0) speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class SyncClockUpdate(BaseModel):
@@ -22,6 +32,16 @@ class SyncClockUpdate(BaseModel):
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0) speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class SyncClockResponse(BaseModel):
@@ -32,6 +52,16 @@ class SyncClockResponse(BaseModel):
speed: float = Field(description="Speed multiplier") speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: 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") is_running: bool = Field(True, description="Whether clock is currently running")
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds") elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
created_at: datetime = Field(description="Creation timestamp") 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") engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class TemplateUpdate(BaseModel):
@@ -24,6 +34,16 @@ class TemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None 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): class TemplateResponse(BaseModel):
@@ -37,6 +57,12 @@ class TemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") 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): class TemplateListResponse(BaseModel):
@@ -17,6 +17,16 @@ class _ValueSourceResponseBase(BaseModel):
name: str = Field(description="Source name") name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update 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) name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class StaticValueSourceCreate(_ValueSourceCreateBase):
@@ -320,6 +340,16 @@ class _ValueSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class StaticValueSourceUpdate(_ValueSourceUpdateBase):
@@ -25,6 +25,16 @@ class WeatherSourceCreate(BaseModel):
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class WeatherSourceUpdate(BaseModel):
@@ -44,6 +54,16 @@ class WeatherSourceUpdate(BaseModel):
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="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): class WeatherSourceResponse(BaseModel):
@@ -60,6 +80,16 @@ class WeatherSourceResponse(BaseModel):
update_interval: int = Field(description="API poll interval in seconds") update_interval: int = Field(description="API poll interval in seconds")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -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}` },
};
}
@@ -404,6 +404,8 @@ export interface GradientEntity {
is_builtin: boolean; is_builtin: boolean;
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
} }
export const gradientsCache = new DataCache<GradientEntity[]>({ 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 { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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 { loadPictureSources } from './streams.ts';
import type { Asset } from '../types.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 = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const ICON_PLAY_SOUND = _icon(P.play); const ICON_PLAY_SOUND = _icon(P.play);
const ICON_UPLOAD = _icon(P.fileUp); const ICON_UPLOAD = _icon(P.fileUp);
@@ -162,6 +176,7 @@ export function createAssetCard(asset: Asset): string {
name: asset.name, name: asset.name,
metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`), metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`),
leds: ['on'], leds: ['on'],
...makeCardIconFields('asset', asset.id, asset),
menu: { menu: {
hideOnclick: `toggleCardHidden('assets','${asset.id}')`, hideOnclick: `toggleCardHidden('assets','${asset.id}')`,
deleteOnclick: `deleteAsset('${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 { FilterListManager } from '../core/filter-list.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts } from '../core/mod-card.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'; 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 ───────────────────────────────────────────── // ── Module state ─────────────────────────────────────────────
let _aptTagsInput: TagInput | null = null; let _aptTagsInput: TagInput | null = null;
@@ -286,6 +300,7 @@ export function createAudioProcessingTemplateCard(tmpl: any): string {
name: tmpl.name, name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`), metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`),
leds: ['off'], leds: ['off'],
...makeCardIconFields('audio_processing_template', tmpl.id, tmpl),
menu: { menu: {
duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`, duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('audio-processing-templates','${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 { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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 { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.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 { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.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 ── // ── HA rule entity cache ──
let _haRuleEntities: any[] = []; let _haRuleEntities: any[] = [];
@@ -401,6 +417,7 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
name: automation.name, name: automation.name,
metaHtml, metaHtml,
leds: [ledState], leds: [ledState],
...makeCardIconFields('automation', automation.id, automation),
menu: { menu: {
duplicateOnclick: `cloneAutomation('${automation.id}')`, duplicateOnclick: `cloneAutomation('${automation.id}')`,
hideOnclick: `toggleCardHidden('automations','${automation.id}')`, hideOnclick: `toggleCardHidden('automations','${automation.id}')`,
@@ -23,6 +23,23 @@ import type { ColorStripSource } from '../../types.ts';
import { bindableValue, bindableColor } from '../../types.ts'; import { bindableValue, bindableColor } from '../../types.ts';
import { renderTagChips } from '../../core/tag-input.ts'; import { renderTagChips } from '../../core/tag-input.ts';
import { rgbArrayToHex } from '../css-gradient-editor.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 ────────────────────────────────────────────────────── */ /* ── Types ────────────────────────────────────────────────────── */
@@ -332,6 +349,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
name: source.name, name: source.name,
metaHtml: escapeHtml(metaText), metaHtml: escapeHtml(metaText),
leds: ['off'], leds: ['off'],
...makeCardIconFields('color_strip_source', source.id, source),
menu: { menu: {
duplicateOnclick: `cloneColorStrip('${source.id}')`, duplicateOnclick: `cloneColorStrip('${source.id}')`,
hideOnclick: `toggleCardHidden('color-strips','${source.id}')`, hideOnclick: `toggleCardHidden('color-strips','${source.id}')`,
@@ -457,8 +457,11 @@ function renderDashboardSyncClock(clock: SyncClock): string {
const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume'); const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
const scStyle = cardColorStyle(clock.id); 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}"` : ''}> 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"> <div class="mod-id">
<span class="mod-badge">CLK · ${escapeHtml(short)}</span> <span class="mod-badge">CLK · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(clock.name)}</span></div> <div class="mod-name"><span>${escapeHtml(clock.name)}</span></div>
@@ -958,6 +961,20 @@ 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. /** Resolve the effective custom icon for a dashboard target card.
* LED targets inherit from their referenced device when no own icon is * LED targets inherit from their referenced device when no own icon is
* set; HA-light targets have no inheritance source. Returns the HTML * set; HA-light targets have no inheritance source. Returns the HTML
@@ -1153,8 +1170,11 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
metaLines.push(`${ICON_SCENE} ${sceneName}`); metaLines.push(`${ICON_SCENE} ${sceneName}`);
const aStyle = cardColorStyle(automation.id); 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}"` : ''}> 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"> <div class="mod-id">
<span class="mod-badge">AUTO · ${escapeHtml(short)}</span> <span class="mod-badge">AUTO · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(automation.name)}</span></div> <div class="mod-name"><span>${escapeHtml(automation.name)}</span></div>
@@ -14,6 +14,8 @@ import { CardSection } from '../core/card-sections.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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 { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
import { import {
ICON_GAMEPAD, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_TRASH, ICON_GAMEPAD, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_TRASH,
@@ -25,6 +27,22 @@ import type {
EffectPreset, EffectPreset,
} from '../types.ts'; } 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>`; const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Bulk actions ── // ── Bulk actions ──
@@ -566,6 +584,7 @@ export function createGameIntegrationCard(gi: GameIntegration): string {
name: gi.name, name: gi.name,
metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`), metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`),
leds, leds,
...makeCardIconFields('game_integration', gi.id, gi),
menu: { menu: {
duplicateOnclick: `cloneGameIntegration('${gi.id}')`, duplicateOnclick: `cloneGameIntegration('${gi.id}')`,
hideOnclick: `toggleCardHidden('game-integrations','${gi.id}')`, hideOnclick: `toggleCardHidden('game-integrations','${gi.id}')`,
@@ -12,8 +12,25 @@ import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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'; 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>`; const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
// ── Modal ── // ── Modal ──
@@ -240,6 +257,7 @@ export function createHASourceCard(source: HomeAssistantSource) {
name: source.name, name: source.name,
metaHtml: escapeHtml(`${source.host}${isConnected ? ` · ${source.entity_count} entities` : ''}`), metaHtml: escapeHtml(`${source.host}${isConnected ? ` · ${source.entity_count} entities` : ''}`),
leds, leds,
...makeCardIconFields('ha_source', source.id, source),
menu: { menu: {
duplicateOnclick: `cloneHASource('${source.id}')`, duplicateOnclick: `cloneHASource('${source.id}')`,
hideOnclick: `toggleCardHidden('ha-sources','${source.id}')`, hideOnclick: `toggleCardHidden('ha-sources','${source.id}')`,
@@ -34,17 +34,22 @@ const RECENT_MAX = 10;
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Entity-type registry // 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.
type EntityType = 'device' | 'target'; export type EntityType = string;
interface EntityRecord { export interface EntityRecord {
id: string; id: string;
name: string; name: string;
icon: string; icon: string;
icon_color: string; icon_color: string;
} }
interface InheritedIcon { export interface InheritedIcon {
/** The icon id we'd render if this entity has no own icon. */ /** The icon id we'd render if this entity has no own icon. */
iconId: string; iconId: string;
/** Effective color for the inherited icon. */ /** Effective color for the inherited icon. */
@@ -53,83 +58,149 @@ interface InheritedIcon {
fromName: string; fromName: string;
} }
interface EntityTypeAdapter { export interface EntityTypeAdapter {
/** Look up the entity by id. Returns null when missing from the cache. */ /** Look up the entity by id. Returns null when missing from the cache. */
lookup(id: string): EntityRecord | null; lookup(id: string): EntityRecord | null;
/** Build the PUT endpoint URL for icon updates. */ /** Build the PUT endpoint URL (path only — fetchWithAuth prepends ``/api/v1``). */
endpoint(id: string): string; endpoint(id: string): string;
/** Invalidate the cache and reload the relevant view. */ /** Invalidate the cache and reload the relevant view. */
reload(): Promise<void>; reload(): Promise<void>;
/** Optional fallback icon (e.g. LED target → parent device). */ /** Optional fallback icon (e.g. LED target → parent device). */
inheritedFrom(id: string): InheritedIcon | null; inheritedFrom(id: string): InheritedIcon | null;
/** Display label like "Device" / "LED target" / "HA light target". */ /** Display label like "Device" / "LED target" / "Picture source". */
typeLabel(id: string): string; 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>;
} }
function _readDevice(id: string): EntityRecord | null { const _adapters: Map<EntityType, EntityTypeAdapter> = new Map();
const dev = (devicesCache.data ?? []).find((d: any) => d.id === id);
if (!dev) return null; /** 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 { return {
id: dev.id, lookup: (id: string) => {
name: dev.name ?? dev.id, const rec = _find(id);
icon: (dev.icon as string | undefined) ?? '', if (!rec) return null;
icon_color: (dev.icon_color as string | undefined) ?? '',
};
}
function _readTarget(id: string): EntityRecord | null {
const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id);
if (!tgt) return null;
return {
id: tgt.id,
name: tgt.name ?? tgt.id,
icon: (tgt.icon as string | undefined) ?? '',
icon_color: (tgt.icon_color as string | undefined) ?? '',
};
}
const _adapters: Record<EntityType, EntityTypeAdapter> = {
device: {
lookup: _readDevice,
endpoint: (id) => `/devices/${id}`,
reload: async () => {
devicesCache.invalidate();
await window.loadDevices?.();
},
inheritedFrom: () => null,
typeLabel: () => t('device.icon.entity.device') || 'Device',
},
target: {
lookup: _readTarget,
endpoint: (id) => `/output-targets/${id}`,
reload: async () => {
outputTargetsCache.invalidate();
await window.loadTargetsTab?.();
},
inheritedFrom: (id) => {
const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id);
if (!tgt) return null;
// Only LED targets inherit from a device — HA-light targets don't.
const deviceId = (tgt as any).device_id;
if (!deviceId) return null;
const dev = (devicesCache.data ?? []).find((d: any) => d.id === deviceId);
const iconId = (dev?.icon as string | undefined) ?? '';
if (!iconId) return null;
return { return {
iconId, id: rec.id,
color: (dev?.icon_color as string | undefined) || '', name: (rec.name as string | undefined) ?? rec.id,
fromName: dev?.name ?? deviceId, icon: (rec.icon as string | undefined) ?? '',
icon_color: (rec.icon_color as string | undefined) ?? '',
}; };
}, },
typeLabel: (id: string) => { endpoint: (id: string) => `${opts.endpointPrefix}/${id}`,
const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id); reload: async () => {
if ((tgt as any)?.target_type === 'ha_light') { opts.cache.invalidate();
return t('device.icon.entity.ha_light_target') || 'HA light target'; await opts.reload();
}
return t('device.icon.entity.target') || 'LED target';
}, },
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 // Picker state
@@ -188,7 +259,7 @@ function _pushRecent(iconId: string): void {
/** Open the picker for the given entity. Reads current icon from cache. */ /** Open the picker for the given entity. Reads current icon from cache. */
export function openIconPicker(entityType: EntityType, entityId: string): void { export function openIconPicker(entityType: EntityType, entityId: string): void {
if (!entityId) return; if (!entityId) return;
const adapter = _adapters[entityType]; const adapter = _adapters.get(entityType);
if (!adapter) return; if (!adapter) return;
const rec = adapter.lookup(entityId); const rec = adapter.lookup(entityId);
@@ -197,13 +268,14 @@ export function openIconPicker(entityType: EntityType, entityId: string): void {
const inherited = adapter.inheritedFrom(entityId); const inherited = adapter.inheritedFrom(entityId);
// Resolve channel color from the live card so the preview matches. // Resolve channel color from the live card so the preview matches.
// LED-target cards use ``data-target-id``; HA-light-target cards use // Adapters provide the candidate selectors; we try them in order
// ``data-ha-target-id``. Try the LED selector first and fall back to // and fall back to the global accent when no card is found.
// the HA-light one when the entity is a target. const selectors = adapter.cardSelectors?.(entityId) ?? [];
const card = entityType === 'device' let card: HTMLElement | null = null;
? document.querySelector(`[data-device-id="${CSS.escape(entityId)}"]`) as HTMLElement | null for (const sel of selectors) {
: (document.querySelector(`[data-target-id="${CSS.escape(entityId)}"]`) card = document.querySelector(sel) as HTMLElement | null;
?? document.querySelector(`[data-ha-target-id="${CSS.escape(entityId)}"]`)) as HTMLElement | null; if (card) break;
}
const channelColor = card const channelColor = card
? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel() ? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel()
: _fallbackChannel(); : _fallbackChannel();
@@ -297,7 +369,7 @@ function _renderModal(): void {
: `<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>`; : `<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. // Header — entity type + name, plus inherited hint when applicable.
const adapter = _adapters[_ctx.entityType]; const adapter = _adapters.get(_ctx.entityType)!;
if (eyebrowEl) { if (eyebrowEl) {
eyebrowEl.textContent = adapter.typeLabel(_ctx.entityId); eyebrowEl.textContent = adapter.typeLabel(_ctx.entityId);
} }
@@ -414,14 +486,13 @@ async function _applyChange(nextIconId: string, nextColor: string): Promise<void
return; return;
} }
const adapter = _adapters[entityType]; const adapter = _adapters.get(entityType)!;
try { try {
const body: Record<string, unknown> = { icon: nextIconId, icon_color: nextColor }; const body: Record<string, unknown> = { icon: nextIconId, icon_color: nextColor };
// The output-targets endpoint requires the discriminator field. // Discriminated routes (e.g. output-targets) need extra fields
if (entityType === 'target') { // — adapter declares them via ``bodyExtras``.
const tgt = (outputTargetsCache.data ?? []).find((x: any) => x.id === entityId); if (adapter.bodyExtras) {
const targetType = (tgt as any)?.target_type ?? 'led'; Object.assign(body, adapter.bodyExtras(entityId));
body.target_type = targetType;
} }
const resp = await fetchWithAuth(adapter.endpoint(entityId), { const resp = await fetchWithAuth(adapter.endpoint(entityId), {
method: 'PUT', method: 'PUT',
@@ -557,9 +628,9 @@ function _onDocumentClick(e: MouseEvent): void {
if (!raw) return; if (!raw) return;
const [typeOrId, id] = raw.includes(':') ? raw.split(':', 2) : ['device', raw]; const [typeOrId, id] = raw.includes(':') ? raw.split(':', 2) : ['device', raw];
if (!id) return; if (!id) return;
if (typeOrId !== 'device' && typeOrId !== 'target') return; if (!_adapters.has(typeOrId)) return;
e.stopPropagation(); e.stopPropagation();
openIconPicker(typeOrId as EntityType, id); openIconPicker(typeOrId, id);
} }
document.addEventListener('click', _onDocumentClick); document.addEventListener('click', _onDocumentClick);
@@ -12,8 +12,25 @@ import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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'; 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>`; const ICON_MQTT = `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`;
// ── Modal ── // ── Modal ──
@@ -250,6 +267,7 @@ export function createMQTTSourceCard(source: MQTTSource) {
name: source.name, name: source.name,
metaHtml: escapeHtml(`${broker} · ${source.base_topic}`), metaHtml: escapeHtml(`${broker} · ${source.base_topic}`),
leds, leds,
...makeCardIconFields('mqtt_source', source.id, source),
menu: { menu: {
duplicateOnclick: `cloneMQTTSource('${source.id}')`, duplicateOnclick: `cloneMQTTSource('${source.id}')`,
hideOnclick: `toggleCardHidden('mqtt-sources','${source.id}')`, hideOnclick: `toggleCardHidden('mqtt-sources','${source.id}')`,
@@ -11,7 +11,7 @@ import { CardSection } from '../core/card-sections.ts';
import { import {
ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_TRASH, ICON_LINK, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_TRASH, ICON_LINK,
} from '../core/icons.ts'; } from '../core/icons.ts';
import { renderDeviceIcon } from '../core/device-icons.ts'; import { renderDeviceIcon, renderDeviceIconSvg } from '../core/device-icons.ts';
import { scenePresetsCache, outputTargetsCache, automationsCacheObj, devicesCache } from '../core/state.ts'; import { scenePresetsCache, outputTargetsCache, automationsCacheObj, devicesCache } from '../core/state.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { wrapCard, cardColorStyle } from '../core/card-colors.ts'; import { wrapCard, cardColorStyle } from '../core/card-colors.ts';
@@ -19,8 +19,24 @@ import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { EntityPalette } from '../core/entity-palette.ts'; import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts'; import { navigateToCard } from '../core/navigation.ts';
import { isActiveTab } from '../core/tab-registry.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'; 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 _editingId: string | null = null;
let _allTargets: any[] = []; // fetched on capture open let _allTargets: any[] = []; // fetched on capture open
let _sceneTagsInput: TagInput | null = null; let _sceneTagsInput: TagInput | null = null;
@@ -117,6 +133,7 @@ export function createSceneCard(preset: ScenePreset) {
name: preset.name, name: preset.name,
metaHtml, metaHtml,
leds, leds,
...makeCardIconFields('scene_preset', preset.id, preset),
menu: { menu: {
duplicateOnclick: `cloneScenePreset('${preset.id}')`, duplicateOnclick: `cloneScenePreset('${preset.id}')`,
hideOnclick: `toggleCardHidden('scenes','${preset.id}')`, hideOnclick: `toggleCardHidden('scenes','${preset.id}')`,
@@ -185,8 +202,18 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
const activateLabel = t('scenes.activate') || 'Activate'; const activateLabel = t('scenes.activate') || 'Activate';
const pStyle = cardColorStyle(preset.id); 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}"` : ''}> 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"> <div class="mod-id">
<span class="mod-badge">SCN \u00b7 ${escapeHtml(short)}</span> <span class="mod-badge">SCN \u00b7 ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(preset.name)}</span></div> <div class="mod-name"><span>${escapeHtml(preset.name)}</span></div>
@@ -72,6 +72,81 @@ import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { FilterListManager } from '../core/filter-list.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 ── // ── TagInput instances for modals ──
let _streamTagsInput: TagInput | null = null; let _streamTagsInput: TagInput | null = null;
@@ -454,6 +529,7 @@ function renderPictureSourcesList(streams: any) {
name: stream.name, name: stream.name,
metaHtml: details.metaHtml, metaHtml: details.metaHtml,
leds: ['off'], leds: ['off'],
...makeCardIconFields('picture_source', stream.id, stream),
menu: { menu: {
duplicateOnclick: `cloneStream('${stream.id}')`, duplicateOnclick: `cloneStream('${stream.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${stream.id}')`, hideOnclick: `toggleCardHidden('${sectionKey}','${stream.id}')`,
@@ -510,6 +586,7 @@ function renderPictureSourcesList(streams: any) {
name: template.name, name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`), metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'], leds: ['off'],
...makeCardIconFields('capture_template', template.id, template),
menu: { menu: {
duplicateOnclick: `cloneCaptureTemplate('${template.id}')`, duplicateOnclick: `cloneCaptureTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('raw-templates','${template.id}')`, hideOnclick: `toggleCardHidden('raw-templates','${template.id}')`,
@@ -556,6 +633,7 @@ function renderPictureSourcesList(streams: any) {
name: tmpl.name, name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`), metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`),
leds: ['off'], leds: ['off'],
...makeCardIconFields('pp_template', tmpl.id, tmpl),
menu: { menu: {
duplicateOnclick: `clonePPTemplate('${tmpl.id}')`, duplicateOnclick: `clonePPTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`, hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`,
@@ -600,6 +678,7 @@ function renderPictureSourcesList(streams: any) {
name: tmpl.name, name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`), metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`),
leds: ['off'], leds: ['off'],
...makeCardIconFields('cspt', tmpl.id, tmpl),
menu: { menu: {
duplicateOnclick: `cloneCSPT('${tmpl.id}')`, duplicateOnclick: `cloneCSPT('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`, hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`,
@@ -795,6 +874,7 @@ function renderPictureSourcesList(streams: any) {
name: src.name, name: src.name,
metaHtml: escapeHtml(metaText), metaHtml: escapeHtml(metaText),
leds: ['off'], leds: ['off'],
...makeCardIconFields('audio_source', src.id, src),
menu: { menu: {
duplicateOnclick: `cloneAudioSource('${src.id}')`, duplicateOnclick: `cloneAudioSource('${src.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`, hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`,
@@ -850,6 +930,7 @@ function renderPictureSourcesList(streams: any) {
name: template.name, name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`), metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'], leds: ['off'],
...makeCardIconFields('audio_template', template.id, template),
menu: { menu: {
duplicateOnclick: `cloneAudioTemplate('${template.id}')`, duplicateOnclick: `cloneAudioTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('audio-templates','${template.id}')`, hideOnclick: `toggleCardHidden('audio-templates','${template.id}')`,
@@ -896,6 +977,7 @@ function renderPictureSourcesList(streams: any) {
name: g.name, name: g.name,
metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`), metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`),
leds: ['off'], leds: ['off'],
...(g.is_builtin ? {} : makeCardIconFields('gradient', g.id, g)),
menu: { menu: {
duplicateOnclick: `cloneGradient('${g.id}')`, duplicateOnclick: `cloneGradient('${g.id}')`,
hideOnclick: `toggleCardHidden('gradients','${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 { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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 { loadPictureSources } from './streams.ts';
import type { SyncClock } from '../types.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 ── // ── Auto-name ──
let _scNameManuallyEdited = false; let _scNameManuallyEdited = false;
@@ -245,6 +263,7 @@ export function createSyncClockCard(clock: SyncClock) {
name: clock.name, name: clock.name,
metaHtml: escapeHtml(`${statusLabel} · ${clock.speed}x`), metaHtml: escapeHtml(`${statusLabel} · ${clock.speed}x`),
leds, leds,
...makeCardIconFields('sync_clock', clock.id, clock),
menu: { menu: {
duplicateOnclick: `cloneSyncClock('${clock.id}')`, duplicateOnclick: `cloneSyncClock('${clock.id}')`,
hideOnclick: `toggleCardHidden('sync-clocks','${clock.id}')`, hideOnclick: `toggleCardHidden('sync-clocks','${clock.id}')`,
@@ -30,8 +30,27 @@ import {
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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 { openAuthedWs } from '../core/ws-auth.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.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 type { IconSelectItem } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
@@ -1398,6 +1417,7 @@ export function createValueSourceCard(src: ValueSource) {
name: src.name, name: src.name,
metaHtml: escapeHtml(metaText), metaHtml: escapeHtml(metaText),
leds: ['off'], leds: ['off'],
...makeCardIconFields('value_source', src.id, src),
menu: { menu: {
duplicateOnclick: `cloneValueSource('${src.id}')`, duplicateOnclick: `cloneValueSource('${src.id}')`,
hideOnclick: `toggleCardHidden('value-sources','${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 { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.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'; 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_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>`; 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, name: source.name,
metaHtml: escapeHtml(`${providerLabel} · ${coords}`), metaHtml: escapeHtml(`${providerLabel} · ${coords}`),
leds: ['on'], leds: ['on'],
...makeCardIconFields('weather_source', source.id, source),
menu: { menu: {
duplicateOnclick: `cloneWeatherSource('${source.id}')`, duplicateOnclick: `cloneWeatherSource('${source.id}')`,
hideOnclick: `toggleCardHidden('weather-sources','${source.id}')`, hideOnclick: `toggleCardHidden('weather-sources','${source.id}')`,
+34
View File
@@ -228,6 +228,8 @@ export interface ColorStripSource {
tags: string[]; tags: string[];
overlay_active: boolean; overlay_active: boolean;
clock_id?: string; clock_id?: string;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -328,6 +330,8 @@ export interface PatternTemplate {
rectangles: KeyColorRectangle[]; rectangles: KeyColorRectangle[];
tags: string[]; tags: string[];
description?: string; description?: string;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -358,6 +362,8 @@ interface ValueSourceBase {
return_type: 'float' | 'color'; return_type: 'float' | 'color';
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -518,6 +524,8 @@ interface AudioSourceBase {
source_type: AudioSourceType; source_type: AudioSourceType;
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -549,6 +557,8 @@ interface PictureSourceBase {
stream_type: PictureSourceType; stream_type: PictureSourceType;
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -608,6 +618,8 @@ export interface ScenePreset {
targets: TargetSnapshot[]; targets: TargetSnapshot[];
order: number; order: number;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -622,6 +634,8 @@ export interface SyncClock {
tags: string[]; tags: string[];
is_running: boolean; is_running: boolean;
elapsed_time: number; elapsed_time: number;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -636,6 +650,8 @@ export interface WeatherSource {
update_interval: number; update_interval: number;
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -657,6 +673,8 @@ export interface HomeAssistantSource {
entity_count: number; entity_count: number;
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -694,6 +712,8 @@ export interface MQTTSource {
connected: boolean; connected: boolean;
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -728,6 +748,8 @@ export interface Asset {
description?: string; description?: string;
tags: string[]; tags: string[];
prebuilt: boolean; prebuilt: boolean;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -772,6 +794,8 @@ export interface Automation {
is_active: boolean; is_active: boolean;
last_activated_at?: string; last_activated_at?: string;
last_deactivated_at?: string; last_deactivated_at?: string;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -790,6 +814,8 @@ export interface CaptureTemplate {
engine_config: Record<string, any>; engine_config: Record<string, any>;
tags: string[]; tags: string[];
description?: string; description?: string;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -800,6 +826,8 @@ export interface PostprocessingTemplate {
filters: FilterInstance[]; filters: FilterInstance[];
tags: string[]; tags: string[];
description?: string; description?: string;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -810,6 +838,8 @@ export interface ColorStripProcessingTemplate {
filters: FilterInstance[]; filters: FilterInstance[];
tags: string[]; tags: string[];
description?: string; description?: string;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -821,6 +851,8 @@ export interface AudioTemplate {
engine_config: Record<string, any>; engine_config: Record<string, any>;
tags: string[]; tags: string[];
description?: string; description?: string;
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -940,6 +972,8 @@ export interface GameIntegration {
enabled: boolean; enabled: boolean;
description?: string; description?: string;
tags: string[]; tags: string[];
icon?: string;
icon_color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
+19
View File
@@ -585,6 +585,25 @@
"device.icon.entity.device": "Device", "device.icon.entity.device": "Device",
"device.icon.entity.target": "LED target", "device.icon.entity.target": "LED target",
"device.icon.entity.ha_light_target": "HA light 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.inherited_from": "Inherited from %s",
"device.icon.override_inherited": "Override inherited icon…", "device.icon.override_inherited": "Override inherited icon…",
"device.icon.use_inherited": "Use inherited", "device.icon.use_inherited": "Use inherited",
+19
View File
@@ -603,6 +603,25 @@
"device.icon.entity.device": "Устройство", "device.icon.entity.device": "Устройство",
"device.icon.entity.target": "LED-цель", "device.icon.entity.target": "LED-цель",
"device.icon.entity.ha_light_target": "HA-светильник", "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.inherited_from": "Унаследовано от %s",
"device.icon.override_inherited": "Заменить унаследованную иконку…", "device.icon.override_inherited": "Заменить унаследованную иконку…",
"device.icon.use_inherited": "Использовать унаследованную", "device.icon.use_inherited": "Использовать унаследованную",
+19
View File
@@ -603,6 +603,25 @@
"device.icon.entity.device": "设备", "device.icon.entity.device": "设备",
"device.icon.entity.target": "LED 目标", "device.icon.entity.target": "LED 目标",
"device.icon.entity.ha_light_target": "HA 灯目标", "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.inherited_from": "继承自 %s",
"device.icon.override_inherited": "覆盖继承的图标…", "device.icon.override_inherited": "覆盖继承的图标…",
"device.icon.use_inherited": "使用继承的", "device.icon.use_inherited": "使用继承的",
+11 -1
View File
@@ -43,9 +43,12 @@ class Asset:
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
prebuilt: bool = False # True for shipped assets prebuilt: bool = False # True for shipped assets
deleted: bool = False # soft-delete for prebuilt 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: def to_dict(self) -> dict:
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"filename": self.filename, "filename": self.filename,
@@ -60,6 +63,11 @@ class Asset:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "Asset": def from_dict(data: dict) -> "Asset":
@@ -75,6 +83,8 @@ class Asset:
tags=data.get("tags", []), tags=data.get("tags", []),
prebuilt=bool(data.get("prebuilt", False)), prebuilt=bool(data.get("prebuilt", False)),
deleted=bool(data.get("deleted", False)), deleted=bool(data.get("deleted", False)),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat(data["created_at"]), created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]), updated_at=datetime.fromisoformat(data["updated_at"]),
) )
+10
View File
@@ -95,6 +95,8 @@ class AssetStore(BaseSqliteStore[Asset]):
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
prebuilt: bool = False, prebuilt: bool = False,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> Asset: ) -> Asset:
"""Create a new asset from uploaded file data. """Create a new asset from uploaded file data.
@@ -156,6 +158,8 @@ class AssetStore(BaseSqliteStore[Asset]):
description=description, description=description,
tags=tags or [], tags=tags or [],
prebuilt=prebuilt, prebuilt=prebuilt,
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[asset_id] = asset self._items[asset_id] = asset
@@ -171,6 +175,8 @@ class AssetStore(BaseSqliteStore[Asset]):
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> Asset: ) -> Asset:
"""Update asset metadata (not the file itself).""" """Update asset metadata (not the file itself)."""
asset = self.get(asset_id) asset = self.get(asset_id)
@@ -182,6 +188,10 @@ class AssetStore(BaseSqliteStore[Asset]):
asset.description = description asset.description = description
if tags is not None: if tags is not None:
asset.tags = tags asset.tags = tags
if icon is not None:
asset.icon = icon or ""
if icon_color is not None:
asset.icon_color = icon_color or ""
asset.updated_at = datetime.now(timezone.utc) asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset_id, asset) self._save_item(asset_id, asset)
@@ -18,10 +18,12 @@ class AudioProcessingTemplate:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert template to dictionary.""" """Convert template to dictionary."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"filters": [f.to_dict() for f in self.filters], "filters": [f.to_dict() for f in self.filters],
@@ -30,6 +32,11 @@ class AudioProcessingTemplate:
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "AudioProcessingTemplate": def from_dict(cls, data: dict) -> "AudioProcessingTemplate":
@@ -52,4 +59,6 @@ class AudioProcessingTemplate:
), ),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -38,6 +38,8 @@ class AudioProcessingTemplateStore(BaseSqliteStore[AudioProcessingTemplate]):
filters: Optional[List[FilterInstance]] = None, filters: Optional[List[FilterInstance]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> AudioProcessingTemplate: ) -> AudioProcessingTemplate:
self._check_name_unique(name) self._check_name_unique(name)
@@ -60,6 +62,8 @@ class AudioProcessingTemplateStore(BaseSqliteStore[AudioProcessingTemplate]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[template_id] = template self._items[template_id] = template
@@ -75,6 +79,8 @@ class AudioProcessingTemplateStore(BaseSqliteStore[AudioProcessingTemplate]):
filters: Optional[List[FilterInstance]] = None, filters: Optional[List[FilterInstance]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> AudioProcessingTemplate: ) -> AudioProcessingTemplate:
template = self.get(template_id) template = self.get(template_id)
@@ -91,6 +97,10 @@ class AudioProcessingTemplateStore(BaseSqliteStore[AudioProcessingTemplate]):
template.description = description template.description = description
if tags is not None: if tags is not None:
template.tags = tags template.tags = tags
if icon is not None:
template.icon = icon
if icon_color is not None:
template.icon_color = icon_color
template.updated_at = datetime.now(timezone.utc) template.updated_at = datetime.now(timezone.utc)
self._save_item(template_id, template) self._save_item(template_id, template)
+10 -1
View File
@@ -21,10 +21,12 @@ class AudioSource:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert source to dictionary. Subclasses extend this.""" """Convert source to dictionary. Subclasses extend this."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"source_type": self.source_type, "source_type": self.source_type,
@@ -39,6 +41,11 @@ class AudioSource:
"audio_source_id": None, "audio_source_id": None,
"audio_processing_template_id": None, "audio_processing_template_id": None,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "AudioSource": def from_dict(data: dict) -> "AudioSource":
@@ -72,6 +79,8 @@ def _parse_common_fields(data: dict) -> dict:
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=created_at, created_at=created_at,
updated_at=updated_at, updated_at=updated_at,
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -54,6 +54,8 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
audio_template_id: Optional[str] = None, audio_template_id: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
audio_processing_template_id: Optional[str] = None, audio_processing_template_id: Optional[str] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> AudioSource: ) -> AudioSource:
self._check_name_unique(name) self._check_name_unique(name)
@@ -83,6 +85,8 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
audio_source_id=audio_source_id, audio_source_id=audio_source_id,
audio_processing_template_id=audio_processing_template_id, audio_processing_template_id=audio_processing_template_id,
) )
@@ -95,6 +99,8 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
device_index=device_index if device_index is not None else -1, device_index=device_index if device_index is not None else -1,
is_loopback=bool(is_loopback) if is_loopback is not None else True, is_loopback=bool(is_loopback) if is_loopback is not None else True,
audio_template_id=audio_template_id, audio_template_id=audio_template_id,
@@ -117,6 +123,8 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
audio_template_id: Optional[str] = None, audio_template_id: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
audio_processing_template_id: Optional[str] = None, audio_processing_template_id: Optional[str] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> AudioSource: ) -> AudioSource:
source = self.get(source_id) source = self.get(source_id)
@@ -128,6 +136,10 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
source.description = description source.description = description
if tags is not None: if tags is not None:
source.tags = tags source.tags = tags
if icon is not None:
source.icon = icon
if icon_color is not None:
source.icon_color = icon_color
if isinstance(source, CaptureAudioSource): if isinstance(source, CaptureAudioSource):
if device_index is not None: if device_index is not None:
+10 -1
View File
@@ -17,10 +17,12 @@ class AudioCaptureTemplate:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert template to dictionary.""" """Convert template to dictionary."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"engine_type": self.engine_type, "engine_type": self.engine_type,
@@ -30,6 +32,11 @@ class AudioCaptureTemplate:
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "AudioCaptureTemplate": def from_dict(cls, data: dict) -> "AudioCaptureTemplate":
@@ -51,4 +58,6 @@ class AudioCaptureTemplate:
), ),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -77,6 +77,8 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]):
engine_config: Dict[str, Any], engine_config: Dict[str, Any],
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> AudioCaptureTemplate: ) -> AudioCaptureTemplate:
self._check_name_unique(name) self._check_name_unique(name)
@@ -91,6 +93,8 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[template_id] = template self._items[template_id] = template
@@ -106,6 +110,8 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]):
engine_config: Optional[Dict[str, Any]] = None, engine_config: Optional[Dict[str, Any]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> AudioCaptureTemplate: ) -> AudioCaptureTemplate:
template = self.get(template_id) template = self.get(template_id)
@@ -120,6 +126,10 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]):
template.description = description template.description = description
if tags is not None: if tags is not None:
template.tags = tags template.tags = tags
if icon is not None:
template.icon = icon
if icon_color is not None:
template.icon_color = icon_color
template.updated_at = datetime.now(timezone.utc) template.updated_at = datetime.now(timezone.utc)
self._save_item(template_id, template) self._save_item(template_id, template)
+11 -1
View File
@@ -243,6 +243,9 @@ class Automation:
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
# Custom card icon (frontend display only)
icon: str = ""
icon_color: str = ""
# Backward-compatible property aliases # Backward-compatible property aliases
@property @property
@@ -262,7 +265,7 @@ class Automation:
self.rules = value self.rules = value
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"enabled": self.enabled, "enabled": self.enabled,
@@ -275,6 +278,11 @@ class Automation:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Automation": def from_dict(cls, data: dict) -> "Automation":
@@ -302,6 +310,8 @@ class Automation:
deactivation_mode=data.get("deactivation_mode", "none"), deactivation_mode=data.get("deactivation_mode", "none"),
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat( created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat()) data.get("created_at", datetime.now(timezone.utc).isoformat())
), ),
@@ -34,6 +34,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: str = "none", deactivation_mode: str = "none",
deactivation_scene_preset_id: Optional[str] = None, deactivation_scene_preset_id: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
# Legacy parameter aliases # Legacy parameter aliases
condition_logic: Optional[str] = None, condition_logic: Optional[str] = None,
conditions: Optional[List[Rule]] = None, conditions: Optional[List[Rule]] = None,
@@ -63,6 +65,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
created_at=now, created_at=now,
updated_at=now, updated_at=now,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[automation_id] = automation self._items[automation_id] = automation
@@ -81,6 +85,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: Optional[str] = None, deactivation_mode: Optional[str] = None,
deactivation_scene_preset_id: str = "__unset__", deactivation_scene_preset_id: str = "__unset__",
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
# Legacy parameter aliases # Legacy parameter aliases
condition_logic: Optional[str] = None, condition_logic: Optional[str] = None,
conditions: Optional[List[Rule]] = None, conditions: Optional[List[Rule]] = None,
@@ -112,6 +118,10 @@ class AutomationStore(BaseSqliteStore[Automation]):
) )
if tags is not None: if tags is not None:
automation.tags = tags automation.tags = tags
if icon is not None:
automation.icon = icon or ""
if icon_color is not None:
automation.icon_color = icon_color or ""
automation.updated_at = datetime.now(timezone.utc) automation.updated_at = datetime.now(timezone.utc)
self._save_item(automation_id, automation) self._save_item(automation_id, automation)
@@ -20,10 +20,12 @@ class ColorStripProcessingTemplate:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert template to dictionary.""" """Convert template to dictionary."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"filters": [f.to_dict() for f in self.filters], "filters": [f.to_dict() for f in self.filters],
@@ -32,6 +34,11 @@ class ColorStripProcessingTemplate:
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "ColorStripProcessingTemplate": def from_dict(cls, data: dict) -> "ColorStripProcessingTemplate":
@@ -54,4 +61,6 @@ class ColorStripProcessingTemplate:
), ),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -75,6 +75,8 @@ class ColorStripProcessingTemplateStore(BaseSqliteStore[ColorStripProcessingTemp
filters: Optional[List[FilterInstance]] = None, filters: Optional[List[FilterInstance]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> ColorStripProcessingTemplate: ) -> ColorStripProcessingTemplate:
self._check_name_unique(name) self._check_name_unique(name)
@@ -94,6 +96,8 @@ class ColorStripProcessingTemplateStore(BaseSqliteStore[ColorStripProcessingTemp
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[template_id] = template self._items[template_id] = template
@@ -109,6 +113,8 @@ class ColorStripProcessingTemplateStore(BaseSqliteStore[ColorStripProcessingTemp
filters: Optional[List[FilterInstance]] = None, filters: Optional[List[FilterInstance]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> ColorStripProcessingTemplate: ) -> ColorStripProcessingTemplate:
template = self.get(template_id) template = self.get(template_id)
@@ -122,6 +128,10 @@ class ColorStripProcessingTemplateStore(BaseSqliteStore[ColorStripProcessingTemp
template.description = description template.description = description
if tags is not None: if tags is not None:
template.tags = tags template.tags = tags
if icon is not None:
template.icon = icon
if icon_color is not None:
template.icon_color = icon_color
template.updated_at = datetime.now(timezone.utc) template.updated_at = datetime.now(timezone.utc)
self._save_item(template_id, template) self._save_item(template_id, template)
@@ -63,6 +63,8 @@ class ColorStripSource:
description: Optional[str] = None description: Optional[str] = None
clock_id: Optional[str] = None # optional SyncClock reference clock_id: Optional[str] = None # optional SyncClock reference
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
@property @property
def sharable(self) -> bool: def sharable(self) -> bool:
@@ -75,7 +77,7 @@ class ColorStripSource:
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert source to dictionary. Subclasses extend this with their own fields.""" """Convert source to dictionary. Subclasses extend this with their own fields."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"source_type": self.source_type, "source_type": self.source_type,
@@ -85,6 +87,11 @@ class ColorStripSource:
"clock_id": self.clock_id, "clock_id": self.clock_id,
"tags": self.tags, "tags": self.tags,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def create_from_kwargs( def create_from_kwargs(
@@ -155,6 +162,8 @@ def _parse_css_common(data: dict) -> dict:
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=created_at, created_at=created_at,
updated_at=updated_at, updated_at=updated_at,
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -59,6 +59,8 @@ class ColorStripStore(BaseSqliteStore[ColorStripSource]):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
tags = kwargs.pop("tags", None) or [] tags = kwargs.pop("tags", None) or []
icon = kwargs.pop("icon", None) or ""
icon_color = kwargs.pop("icon_color", None) or ""
source = ColorStripSource.create_instance( source = ColorStripSource.create_instance(
source_type, source_type,
@@ -69,6 +71,8 @@ class ColorStripStore(BaseSqliteStore[ColorStripSource]):
tags=tags, tags=tags,
**kwargs, **kwargs,
) )
source.icon = icon
source.icon_color = icon_color
self._items[source_id] = source self._items[source_id] = source
self._save_item(source_id, source) self._save_item(source_id, source)
@@ -110,6 +114,14 @@ class ColorStripStore(BaseSqliteStore[ColorStripSource]):
if tags is not None: if tags is not None:
source.tags = tags source.tags = tags
icon = kwargs.pop("icon", None)
if icon is not None:
source.icon = icon
icon_color = kwargs.pop("icon_color", None)
if icon_color is not None:
source.icon_color = icon_color
# -- Type-specific fields -- # -- Type-specific fields --
source.apply_update(**kwargs) source.apply_update(**kwargs)
+18 -1
View File
@@ -80,9 +80,11 @@ class GameIntegrationConfig:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"adapter_type": self.adapter_type, "adapter_type": self.adapter_type,
@@ -94,6 +96,11 @@ class GameIntegrationConfig:
"description": self.description, "description": self.description,
"tags": list(self.tags), "tags": list(self.tags),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "GameIntegrationConfig": def from_dict(cls, data: dict) -> "GameIntegrationConfig":
@@ -117,6 +124,8 @@ class GameIntegrationConfig:
), ),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
) )
@staticmethod @staticmethod
@@ -128,6 +137,8 @@ class GameIntegrationConfig:
event_mappings: Optional[List[EventMapping]] = None, event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> "GameIntegrationConfig": ) -> "GameIntegrationConfig":
"""Factory method to create a new config with generated ID and timestamps.""" """Factory method to create a new config with generated ID and timestamps."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -142,6 +153,8 @@ class GameIntegrationConfig:
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
def apply_update( def apply_update(
@@ -153,6 +166,8 @@ class GameIntegrationConfig:
event_mappings: Optional[List[EventMapping]] = None, event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> "GameIntegrationConfig": ) -> "GameIntegrationConfig":
"""Return a new config with updated fields (immutable update).""" """Return a new config with updated fields (immutable update)."""
return GameIntegrationConfig( return GameIntegrationConfig(
@@ -166,4 +181,6 @@ class GameIntegrationConfig:
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
description=description if description is not None else self.description, description=description if description is not None else self.description,
tags=tags if tags is not None else self.tags, tags=tags if tags is not None else self.tags,
icon=icon if icon is not None else self.icon,
icon_color=icon_color if icon_color is not None else self.icon_color,
) )
@@ -41,6 +41,8 @@ class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]):
event_mappings: Optional[List[EventMapping]] = None, event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> GameIntegrationConfig: ) -> GameIntegrationConfig:
"""Create a new game integration config. """Create a new game integration config.
@@ -70,6 +72,8 @@ class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]):
event_mappings=event_mappings, event_mappings=event_mappings,
description=description, description=description,
tags=tags, tags=tags,
icon=icon,
icon_color=icon_color,
) )
self._items[config.id] = config self._items[config.id] = config
@@ -88,6 +92,8 @@ class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]):
event_mappings: Optional[List[EventMapping]] = None, event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> GameIntegrationConfig: ) -> GameIntegrationConfig:
"""Update an existing game integration config. """Update an existing game integration config.
@@ -122,6 +128,8 @@ class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]):
event_mappings=event_mappings, event_mappings=event_mappings,
description=description, description=description,
tags=tags, tags=tags,
icon=icon,
icon_color=icon_color,
) )
self._items[integration_id] = updated self._items[integration_id] = updated
+10 -1
View File
@@ -22,9 +22,11 @@ class Gradient:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"stops": self.stops, "stops": self.stops,
@@ -34,6 +36,11 @@ class Gradient:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "Gradient": def from_dict(data: dict) -> "Gradient":
@@ -46,6 +53,8 @@ class Gradient:
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=datetime.fromisoformat(data["created_at"]), created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]), updated_at=datetime.fromisoformat(data["updated_at"]),
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@classmethod @classmethod
@@ -130,6 +130,8 @@ class GradientStore(BaseSqliteStore[Gradient]):
stops: list, stops: list,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> Gradient: ) -> Gradient:
self._check_name_unique(name) self._check_name_unique(name)
gid = f"gr_{uuid.uuid4().hex[:8]}" gid = f"gr_{uuid.uuid4().hex[:8]}"
@@ -143,6 +145,8 @@ class GradientStore(BaseSqliteStore[Gradient]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[gid] = gradient self._items[gid] = gradient
self._save_item(gid, gradient) self._save_item(gid, gradient)
@@ -156,6 +160,8 @@ class GradientStore(BaseSqliteStore[Gradient]):
stops: Optional[list] = None, stops: Optional[list] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> Gradient: ) -> Gradient:
gradient = self.get(gradient_id) gradient = self.get(gradient_id)
if gradient.is_builtin: if gradient.is_builtin:
@@ -169,6 +175,10 @@ class GradientStore(BaseSqliteStore[Gradient]):
gradient.description = description gradient.description = description
if tags is not None: if tags is not None:
gradient.tags = tags gradient.tags = tags
if icon is not None:
gradient.icon = icon
if icon_color is not None:
gradient.icon_color = icon_color
gradient.updated_at = datetime.now(timezone.utc) gradient.updated_at = datetime.now(timezone.utc)
self._save_item(gradient_id, gradient) self._save_item(gradient_id, gradient)
logger.info(f"Updated gradient: {gradient_id}") logger.info(f"Updated gradient: {gradient_id}")
@@ -50,6 +50,8 @@ class HomeAssistantSource:
) # optional allowlist (e.g. ["sensor.*"]) ) # optional allowlist (e.g. ["sensor.*"])
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
@property @property
def ws_url(self) -> str: def ws_url(self) -> str:
@@ -72,7 +74,7 @@ class HomeAssistantSource:
# Always persist the token in encrypted envelope form. If the field # Always persist the token in encrypted envelope form. If the field
# already contains an envelope, encrypt() is a no-op. # already contains an envelope, encrypt() is a no-op.
stored_token = secret_box.encrypt(self.token) if self.token else "" stored_token = secret_box.encrypt(self.token) if self.token else ""
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"host": self.host, "host": self.host,
@@ -84,6 +86,11 @@ class HomeAssistantSource:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "HomeAssistantSource": def from_dict(data: dict) -> "HomeAssistantSource":
@@ -99,4 +106,6 @@ class HomeAssistantSource:
token=token, token=token,
use_ssl=data.get("use_ssl", False), use_ssl=data.get("use_ssl", False),
entity_filters=data.get("entity_filters") or [], entity_filters=data.get("entity_filters") or [],
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
) )
@@ -73,6 +73,8 @@ class HomeAssistantStore(BaseSqliteStore[HomeAssistantSource]):
entity_filters: Optional[List[str]] = None, entity_filters: Optional[List[str]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> HomeAssistantSource: ) -> HomeAssistantSource:
if not host: if not host:
raise ValueError("host is required") raise ValueError("host is required")
@@ -95,6 +97,8 @@ class HomeAssistantStore(BaseSqliteStore[HomeAssistantSource]):
entity_filters=entity_filters or [], entity_filters=entity_filters or [],
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[sid] = source self._items[sid] = source
@@ -112,6 +116,8 @@ class HomeAssistantStore(BaseSqliteStore[HomeAssistantSource]):
entity_filters: Optional[List[str]] = None, entity_filters: Optional[List[str]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> HomeAssistantSource: ) -> HomeAssistantSource:
existing = self.get(source_id) existing = self.get(source_id)
@@ -131,6 +137,8 @@ class HomeAssistantStore(BaseSqliteStore[HomeAssistantSource]):
), ),
description=description if description is not None else existing.description, description=description if description is not None else existing.description,
tags=tags if tags is not None else existing.tags, tags=tags if tags is not None else existing.tags,
icon=icon if icon is not None else existing.icon,
icon_color=icon_color if icon_color is not None else existing.icon_color,
) )
self._items[source_id] = updated self._items[source_id] = updated
+10 -1
View File
@@ -48,9 +48,11 @@ class MQTTSource:
base_topic: str = "ledgrab" base_topic: str = "ledgrab"
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"broker_host": self.broker_host, "broker_host": self.broker_host,
@@ -64,6 +66,11 @@ class MQTTSource:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "MQTTSource": def from_dict(data: dict) -> "MQTTSource":
@@ -76,4 +83,6 @@ class MQTTSource:
password=data.get("password", ""), password=data.get("password", ""),
client_id=data.get("client_id", "ledgrab"), client_id=data.get("client_id", "ledgrab"),
base_topic=data.get("base_topic", "ledgrab"), base_topic=data.get("base_topic", "ledgrab"),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
) )
@@ -37,6 +37,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
base_topic: str = "ledgrab", base_topic: str = "ledgrab",
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> MQTTSource: ) -> MQTTSource:
if not broker_host: if not broker_host:
raise ValueError("broker_host is required") raise ValueError("broker_host is required")
@@ -59,6 +61,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
base_topic=base_topic, base_topic=base_topic,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[sid] = source self._items[sid] = source
@@ -78,6 +82,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
base_topic: Optional[str] = None, base_topic: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> MQTTSource: ) -> MQTTSource:
existing = self.get(source_id) existing = self.get(source_id)
@@ -97,6 +103,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
base_topic=base_topic if base_topic is not None else existing.base_topic, base_topic=base_topic if base_topic is not None else existing.base_topic,
description=description if description is not None else existing.description, description=description if description is not None else existing.description,
tags=tags if tags is not None else existing.tags, tags=tags if tags is not None else existing.tags,
icon=icon if icon is not None else existing.icon,
icon_color=icon_color if icon_color is not None else existing.icon_color,
) )
self._items[source_id] = updated self._items[source_id] = updated
+10 -1
View File
@@ -46,10 +46,12 @@ class PatternTemplate:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary.""" """Convert to dictionary."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"rectangles": [r.to_dict() for r in self.rectangles], "rectangles": [r.to_dict() for r in self.rectangles],
@@ -58,6 +60,11 @@ class PatternTemplate:
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "PatternTemplate": def from_dict(cls, data: dict) -> "PatternTemplate":
@@ -79,4 +86,6 @@ class PatternTemplate:
), ),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -60,6 +60,8 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]):
rectangles: Optional[List[KeyColorRectangle]] = None, rectangles: Optional[List[KeyColorRectangle]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> PatternTemplate: ) -> PatternTemplate:
self._check_name_unique(name) self._check_name_unique(name)
@@ -77,6 +79,8 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[template_id] = template self._items[template_id] = template
@@ -92,6 +96,8 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]):
rectangles: Optional[List[KeyColorRectangle]] = None, rectangles: Optional[List[KeyColorRectangle]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> PatternTemplate: ) -> PatternTemplate:
template = self.get(template_id) template = self.get(template_id)
@@ -104,6 +110,10 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]):
template.description = description template.description = description
if tags is not None: if tags is not None:
template.tags = tags template.tags = tags
if icon is not None:
template.icon = icon
if icon_color is not None:
template.icon_color = icon_color
template.updated_at = datetime.now(timezone.utc) template.updated_at = datetime.now(timezone.utc)
self._save_item(template_id, template) self._save_item(template_id, template)
+10 -1
View File
@@ -23,10 +23,12 @@ class PictureSource:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert stream to dictionary. Subclasses extend this.""" """Convert stream to dictionary. Subclasses extend this."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"stream_type": self.stream_type, "stream_type": self.stream_type,
@@ -50,6 +52,11 @@ class PictureSource:
"resolution_limit": None, "resolution_limit": None,
"clock_id": None, "clock_id": None,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "PictureSource": def from_dict(data: dict) -> "PictureSource":
@@ -80,6 +87,8 @@ def _parse_common_fields(data: dict) -> dict:
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=created_at, created_at=created_at,
updated_at=updated_at, updated_at=updated_at,
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -93,6 +93,8 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
end_time: Optional[float] = None, end_time: Optional[float] = None,
resolution_limit: Optional[int] = None, resolution_limit: Optional[int] = None,
clock_id: Optional[str] = None, clock_id: Optional[str] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> PictureSource: ) -> PictureSource:
"""Create a new picture source. """Create a new picture source.
@@ -141,6 +143,8 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
stream: PictureSource stream: PictureSource
@@ -201,6 +205,8 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
end_time: Optional[float] = None, end_time: Optional[float] = None,
resolution_limit: Optional[int] = None, resolution_limit: Optional[int] = None,
clock_id: Optional[str] = None, clock_id: Optional[str] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> PictureSource: ) -> PictureSource:
"""Update an existing picture source. """Update an existing picture source.
@@ -228,6 +234,10 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
stream.description = description stream.description = description
if tags is not None: if tags is not None:
stream.tags = tags stream.tags = tags
if icon is not None:
stream.icon = icon
if icon_color is not None:
stream.icon_color = icon_color
if isinstance(stream, ScreenCapturePictureSource): if isinstance(stream, ScreenCapturePictureSource):
if display_index is not None: if display_index is not None:
@@ -18,10 +18,12 @@ class PostprocessingTemplate:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert template to dictionary.""" """Convert template to dictionary."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"filters": [f.to_dict() for f in self.filters], "filters": [f.to_dict() for f in self.filters],
@@ -30,6 +32,11 @@ class PostprocessingTemplate:
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "PostprocessingTemplate": def from_dict(cls, data: dict) -> "PostprocessingTemplate":
@@ -52,4 +59,6 @@ class PostprocessingTemplate:
), ),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "",
) )
@@ -68,6 +68,8 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
filters: Optional[List[FilterInstance]] = None, filters: Optional[List[FilterInstance]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> PostprocessingTemplate: ) -> PostprocessingTemplate:
self._check_name_unique(name) self._check_name_unique(name)
@@ -90,6 +92,8 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[template_id] = template self._items[template_id] = template
@@ -105,6 +109,8 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
filters: Optional[List[FilterInstance]] = None, filters: Optional[List[FilterInstance]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> PostprocessingTemplate: ) -> PostprocessingTemplate:
template = self.get(template_id) template = self.get(template_id)
@@ -121,6 +127,10 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
template.description = description template.description = description
if tags is not None: if tags is not None:
template.tags = tags template.tags = tags
if icon is not None:
template.icon = icon
if icon_color is not None:
template.icon_color = icon_color
template.updated_at = datetime.now(timezone.utc) template.updated_at = datetime.now(timezone.utc)
self._save_item(template_id, template) self._save_item(template_id, template)
+11 -1
View File
@@ -43,13 +43,16 @@ class ScenePreset:
name: str name: str
description: str = "" description: str = ""
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
# Custom card icon (frontend display only)
icon: str = ""
icon_color: str = ""
targets: List[TargetSnapshot] = field(default_factory=list) targets: List[TargetSnapshot] = field(default_factory=list)
order: int = 0 order: int = 0
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
@@ -59,6 +62,11 @@ class ScenePreset:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "ScenePreset": def from_dict(cls, data: dict) -> "ScenePreset":
@@ -67,6 +75,8 @@ class ScenePreset:
name=data["name"], name=data["name"],
description=data.get("description", ""), description=data.get("description", ""),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])], targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
order=data.get("order", 0), order=data.get("order", 0),
created_at=datetime.fromisoformat( created_at=datetime.fromisoformat(
@@ -48,6 +48,8 @@ class ScenePresetStore(BaseSqliteStore[ScenePreset]):
order: Optional[int] = None, order: Optional[int] = None,
targets: Optional[List[TargetSnapshot]] = None, targets: Optional[List[TargetSnapshot]] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> ScenePreset: ) -> ScenePreset:
preset = self.get(preset_id) preset = self.get(preset_id)
@@ -62,6 +64,10 @@ class ScenePresetStore(BaseSqliteStore[ScenePreset]):
preset.targets = targets preset.targets = targets
if tags is not None: if tags is not None:
preset.tags = tags preset.tags = tags
if icon is not None:
preset.icon = icon or ""
if icon_color is not None:
preset.icon_color = icon_color or ""
preset.updated_at = datetime.now(timezone.utc) preset.updated_at = datetime.now(timezone.utc)
self._save_item(preset_id, preset) self._save_item(preset_id, preset)
+10 -1
View File
@@ -21,9 +21,11 @@ class SyncClock:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"speed": self.speed, "speed": self.speed,
@@ -32,6 +34,11 @@ class SyncClock:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "SyncClock": def from_dict(data: dict) -> "SyncClock":
@@ -43,4 +50,6 @@ class SyncClock:
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=datetime.fromisoformat(data["created_at"]), created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]), updated_at=datetime.fromisoformat(data["updated_at"]),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
) )
@@ -30,6 +30,8 @@ class SyncClockStore(BaseSqliteStore[SyncClock]):
speed: float = 1.0, speed: float = 1.0,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> SyncClock: ) -> SyncClock:
self._check_name_unique(name) self._check_name_unique(name)
cid = f"sc_{uuid.uuid4().hex[:8]}" cid = f"sc_{uuid.uuid4().hex[:8]}"
@@ -43,6 +45,8 @@ class SyncClockStore(BaseSqliteStore[SyncClock]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[cid] = clock self._items[cid] = clock
@@ -57,6 +61,8 @@ class SyncClockStore(BaseSqliteStore[SyncClock]):
speed: Optional[float] = None, speed: Optional[float] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> SyncClock: ) -> SyncClock:
clock = self.get(clock_id) clock = self.get(clock_id)
@@ -69,6 +75,10 @@ class SyncClockStore(BaseSqliteStore[SyncClock]):
clock.description = description clock.description = description
if tags is not None: if tags is not None:
clock.tags = tags clock.tags = tags
if icon is not None:
clock.icon = icon
if icon_color is not None:
clock.icon_color = icon_color
clock.updated_at = datetime.now(timezone.utc) clock.updated_at = datetime.now(timezone.utc)
self._save_item(clock_id, clock) self._save_item(clock_id, clock)
+10 -1
View File
@@ -17,6 +17,8 @@ class CaptureTemplate:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert template to dictionary. """Convert template to dictionary.
@@ -24,7 +26,7 @@ class CaptureTemplate:
Returns: Returns:
Dictionary representation Dictionary representation
""" """
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"engine_type": self.engine_type, "engine_type": self.engine_type,
@@ -34,6 +36,11 @@ class CaptureTemplate:
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "CaptureTemplate": def from_dict(cls, data: dict) -> "CaptureTemplate":
@@ -62,4 +69,6 @@ class CaptureTemplate:
), ),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
) )
@@ -71,6 +71,8 @@ class TemplateStore(BaseSqliteStore[CaptureTemplate]):
engine_config: Dict[str, Any], engine_config: Dict[str, Any],
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> CaptureTemplate: ) -> CaptureTemplate:
self._check_name_unique(name) self._check_name_unique(name)
@@ -85,6 +87,8 @@ class TemplateStore(BaseSqliteStore[CaptureTemplate]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[template_id] = template self._items[template_id] = template
@@ -101,6 +105,8 @@ class TemplateStore(BaseSqliteStore[CaptureTemplate]):
engine_config: Optional[Dict[str, Any]] = None, engine_config: Optional[Dict[str, Any]] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> CaptureTemplate: ) -> CaptureTemplate:
template = self.get(template_id) template = self.get(template_id)
@@ -115,6 +121,10 @@ class TemplateStore(BaseSqliteStore[CaptureTemplate]):
template.description = description template.description = description
if tags is not None: if tags is not None:
template.tags = tags template.tags = tags
if icon is not None:
template.icon = icon
if icon_color is not None:
template.icon_color = icon_color
template.updated_at = datetime.now(timezone.utc) template.updated_at = datetime.now(timezone.utc)
self._save_item(template_id, template) self._save_item(template_id, template)
+10 -1
View File
@@ -36,10 +36,12 @@ class ValueSource:
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert source to dictionary. Subclasses extend this.""" """Convert source to dictionary. Subclasses extend this."""
return { d: dict = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"source_type": self.source_type, "source_type": self.source_type,
@@ -65,6 +67,11 @@ class ValueSource:
"latitude": None, "latitude": None,
"longitude": None, "longitude": None,
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "ValueSource": def from_dict(data: dict) -> "ValueSource":
@@ -95,6 +102,8 @@ def _parse_common_fields(data: dict) -> dict:
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=created_at, created_at=created_at,
updated_at=updated_at, updated_at=updated_at,
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
) )
@@ -85,6 +85,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
sensor_label: Optional[str] = None, sensor_label: Optional[str] = None,
poll_interval: Optional[float] = None, poll_interval: Optional[float] = None,
clock_id: Optional[str] = None, clock_id: Optional[str] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> ValueSource: ) -> ValueSource:
_VALID = ( _VALID = (
"static", "static",
@@ -110,6 +112,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
common_tags = tags or [] common_tags = tags or []
common_icon = icon or ""
common_icon_color = icon_color or ""
if source_type == "static": if source_type == "static":
source: ValueSource = StaticValueSource( source: ValueSource = StaticValueSource(
@@ -120,6 +124,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
value=value if value is not None else 1.0, value=value if value is not None else 1.0,
) )
elif source_type == "animated": elif source_type == "animated":
@@ -131,6 +137,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
waveform=waveform or "sine", waveform=waveform or "sine",
speed=speed if speed is not None else 10.0, speed=speed if speed is not None else 10.0,
min_value=min_value if min_value is not None else 0.0, min_value=min_value if min_value is not None else 0.0,
@@ -145,6 +153,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
audio_source_id=audio_source_id or "", audio_source_id=audio_source_id or "",
mode=mode or "rms", mode=mode or "rms",
sensitivity=sensitivity if sensitivity is not None else 1.0, sensitivity=sensitivity if sensitivity is not None else 1.0,
@@ -165,6 +175,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
schedule=schedule_data, schedule=schedule_data,
min_value=min_value if min_value is not None else 0.0, min_value=min_value if min_value is not None else 0.0,
max_value=max_value if max_value is not None else 1.0, max_value=max_value if max_value is not None else 1.0,
@@ -178,6 +190,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
picture_source_id=picture_source_id or "", picture_source_id=picture_source_id or "",
scene_behavior=scene_behavior or "complement", scene_behavior=scene_behavior or "complement",
sensitivity=sensitivity if sensitivity is not None else 1.0, sensitivity=sensitivity if sensitivity is not None else 1.0,
@@ -194,6 +208,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
speed=speed if speed is not None else 1.0, speed=speed if speed is not None else 1.0,
use_real_time=bool(use_real_time) if use_real_time is not None else False, use_real_time=bool(use_real_time) if use_real_time is not None else False,
latitude=latitude if latitude is not None else 50.0, latitude=latitude if latitude is not None else 50.0,
@@ -210,6 +226,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
color=color if isinstance(color, list) and len(color) == 3 else [255, 255, 255], color=color if isinstance(color, list) and len(color) == 3 else [255, 255, 255],
) )
elif source_type == "animated_color": elif source_type == "animated_color":
@@ -221,6 +239,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
colors=( colors=(
colors colors
if isinstance(colors, list) and len(colors) >= 2 if isinstance(colors, list) and len(colors) >= 2
@@ -242,6 +262,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
schedule=schedule_data, schedule=schedule_data,
) )
elif source_type == "ha_entity": elif source_type == "ha_entity":
@@ -257,6 +279,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
ha_source_id=ha_source_id, ha_source_id=ha_source_id,
entity_id=entity_id, entity_id=entity_id,
attribute=attribute or "", attribute=attribute or "",
@@ -275,6 +299,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
value_source_id=value_source_id, value_source_id=value_source_id,
gradient_id=gradient_id or "", gradient_id=gradient_id or "",
easing=easing or "linear", easing=easing or "linear",
@@ -290,6 +316,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
led_start=led_start if led_start is not None else 0, led_start=led_start if led_start is not None else 0,
led_end=led_end if led_end is not None else -1, led_end=led_end if led_end is not None else -1,
@@ -306,6 +334,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
updated_at=now, updated_at=now,
description=description, description=description,
tags=common_tags, tags=common_tags,
icon=common_icon,
icon_color=common_icon_color,
metric=m, metric=m,
min_value=min_value if min_value is not None else 0.0, min_value=min_value if min_value is not None else 0.0,
max_value=max_value if max_value is not None else 100.0, max_value=max_value if max_value is not None else 100.0,
@@ -363,6 +393,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
sensor_label: Optional[str] = None, sensor_label: Optional[str] = None,
poll_interval: Optional[float] = None, poll_interval: Optional[float] = None,
clock_id: Optional[str] = None, clock_id: Optional[str] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> ValueSource: ) -> ValueSource:
source = self.get(source_id) source = self.get(source_id)
@@ -374,6 +406,10 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
source.description = description source.description = description
if tags is not None: if tags is not None:
source.tags = tags source.tags = tags
if icon is not None:
source.icon = icon
if icon_color is not None:
source.icon_color = icon_color
if isinstance(source, StaticValueSource): if isinstance(source, StaticValueSource):
if value is not None: if value is not None:
+10 -1
View File
@@ -46,9 +46,11 @@ class WeatherSource:
update_interval: int = 600 # seconds (10 min default) update_interval: int = 600 # seconds (10 min default)
description: Optional[str] = None description: Optional[str] = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { d = {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"provider": self.provider, "provider": self.provider,
@@ -61,6 +63,11 @@ class WeatherSource:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@staticmethod @staticmethod
def from_dict(data: dict) -> "WeatherSource": def from_dict(data: dict) -> "WeatherSource":
@@ -72,4 +79,6 @@ class WeatherSource:
latitude=data.get("latitude", 50.0), latitude=data.get("latitude", 50.0),
longitude=data.get("longitude", 0.0), longitude=data.get("longitude", 0.0),
update_interval=data.get("update_interval", 600), update_interval=data.get("update_interval", 600),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
) )
@@ -36,6 +36,8 @@ class WeatherSourceStore(BaseSqliteStore[WeatherSource]):
update_interval: int = 600, update_interval: int = 600,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> WeatherSource: ) -> WeatherSource:
from ledgrab.core.weather.weather_provider import PROVIDER_REGISTRY from ledgrab.core.weather.weather_provider import PROVIDER_REGISTRY
@@ -65,6 +67,8 @@ class WeatherSourceStore(BaseSqliteStore[WeatherSource]):
update_interval=update_interval, update_interval=update_interval,
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "",
icon_color=icon_color or "",
) )
self._items[sid] = source self._items[sid] = source
@@ -83,6 +87,8 @@ class WeatherSourceStore(BaseSqliteStore[WeatherSource]):
update_interval: Optional[int] = None, update_interval: Optional[int] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
) -> WeatherSource: ) -> WeatherSource:
existing = self.get(source_id) existing = self.get(source_id)
@@ -118,6 +124,8 @@ class WeatherSourceStore(BaseSqliteStore[WeatherSource]):
), ),
description=description if description is not None else existing.description, description=description if description is not None else existing.description,
tags=tags if tags is not None else existing.tags, tags=tags if tags is not None else existing.tags,
icon=icon if icon is not None else existing.icon,
icon_color=icon_color if icon_color is not None else existing.icon_color,
) )
self._items[source_id] = updated self._items[source_id] = updated