Add tags to all entity types with chip-based input and autocomplete
- Add `tags: List[str]` field to all 13 entity types (devices, output targets, CSS sources, picture sources, audio sources, value sources, sync clocks, automations, scene presets, capture/audio/PP/pattern templates) - Update all stores, schemas, and route handlers for tag CRUD - Add GET /api/v1/tags endpoint aggregating unique tags across all stores - Create TagInput component with chip display, autocomplete dropdown, keyboard navigation, and API-backed suggestions - Display tag chips on all entity cards (searchable via existing text filter) - Add tag input to all 14 editor modals with dirty check support - Add CSS styles and i18n keys (en/ru/zh) for tag UI - Also includes code review fixes: thread safety, perf, store dedup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
channel=getattr(source, "channel", None),
|
||||
description=source.description,
|
||||
tags=getattr(source, 'tags', []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -81,6 +82,7 @@ async def create_audio_source(
|
||||
channel=data.channel,
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
@@ -119,6 +121,7 @@ async def update_audio_source(
|
||||
channel=data.channel,
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -41,7 +41,8 @@ async def list_audio_templates(
|
||||
responses = [
|
||||
AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, created_at=t.created_at,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
for t in templates
|
||||
@@ -63,10 +64,12 @@ async def create_audio_template(
|
||||
template = store.create_template(
|
||||
name=data.name, engine_type=data.engine_type,
|
||||
engine_config=data.engine_config, description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return AudioTemplateResponse(
|
||||
id=template.id, name=template.name, engine_type=template.engine_type,
|
||||
engine_config=template.engine_config, created_at=template.created_at,
|
||||
engine_config=template.engine_config, tags=getattr(template, 'tags', []),
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at, description=template.description,
|
||||
)
|
||||
except ValueError as e:
|
||||
@@ -89,7 +92,8 @@ async def get_audio_template(
|
||||
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
|
||||
return AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, created_at=t.created_at,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
|
||||
@@ -106,11 +110,12 @@ async def update_audio_template(
|
||||
t = store.update_template(
|
||||
template_id=template_id, name=data.name,
|
||||
engine_type=data.engine_type, engine_config=data.engine_config,
|
||||
description=data.description,
|
||||
description=data.description, tags=data.tags,
|
||||
)
|
||||
return AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, created_at=t.created_at,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -107,6 +107,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
||||
is_active=state["is_active"],
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
tags=getattr(automation, 'tags', []),
|
||||
created_at=automation.created_at,
|
||||
updated_at=automation.updated_at,
|
||||
)
|
||||
@@ -167,6 +168,7 @@ async def create_automation(
|
||||
scene_preset_id=data.scene_preset_id,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||
tags=data.tags,
|
||||
)
|
||||
|
||||
if automation.enabled:
|
||||
@@ -256,6 +258,7 @@ async def update_automation(
|
||||
condition_logic=data.condition_logic,
|
||||
conditions=conditions,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
tags=data.tags,
|
||||
)
|
||||
if data.scene_preset_id is not None:
|
||||
update_kwargs["scene_preset_id"] = data.scene_preset_id
|
||||
|
||||
@@ -100,6 +100,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
app_filter_list=getattr(source, "app_filter_list", None),
|
||||
os_listener=getattr(source, "os_listener", None),
|
||||
overlay_active=overlay_active,
|
||||
tags=getattr(source, 'tags', []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -190,6 +191,7 @@ async def create_color_strip_source(
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _css_to_response(source)
|
||||
|
||||
@@ -273,11 +275,12 @@ async def update_color_strip_source(
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
tags=data.tags,
|
||||
)
|
||||
|
||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||
try:
|
||||
manager._color_strip_stream_manager.update_source(source_id, source)
|
||||
manager.color_strip_stream_manager.update_source(source_id, source)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
|
||||
|
||||
@@ -354,7 +357,7 @@ async def test_css_calibration(
|
||||
"""
|
||||
try:
|
||||
# Validate device exists in manager
|
||||
if body.device_id not in manager._devices:
|
||||
if not manager.has_device(body.device_id):
|
||||
raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found")
|
||||
|
||||
# Validate edge names and colors
|
||||
@@ -500,7 +503,7 @@ async def push_colors(
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
|
||||
|
||||
streams = manager._color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
@@ -537,7 +540,7 @@ async def notify_source(
|
||||
app_name = body.app if body else None
|
||||
color_override = body.color if body else None
|
||||
|
||||
streams = manager._color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
accepted = 0
|
||||
for stream in streams:
|
||||
if hasattr(stream, "fire"):
|
||||
@@ -624,7 +627,7 @@ async def css_api_input_ws(
|
||||
continue
|
||||
|
||||
# Push to all running streams
|
||||
streams = manager._color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
|
||||
@@ -52,6 +52,7 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
rgbw=device.rgbw,
|
||||
zone_mode=device.zone_mode,
|
||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||
tags=getattr(device, 'tags', []),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -126,6 +127,7 @@ async def create_device(
|
||||
send_latency_ms=device_data.send_latency_ms or 0,
|
||||
rgbw=device_data.rgbw or False,
|
||||
zone_mode=device_data.zone_mode or "combined",
|
||||
tags=device_data.tags,
|
||||
)
|
||||
|
||||
# WS devices: auto-set URL to ws://{device_id}
|
||||
@@ -308,6 +310,7 @@ async def update_device(
|
||||
send_latency_ms=update_data.send_latency_ms,
|
||||
rgbw=update_data.rgbw,
|
||||
zone_mode=update_data.zone_mode,
|
||||
tags=update_data.tags,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
@@ -322,11 +325,12 @@ async def update_device(
|
||||
pass
|
||||
|
||||
# Sync auto_shutdown and zone_mode in runtime state
|
||||
if device_id in manager._devices:
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds:
|
||||
if update_data.auto_shutdown is not None:
|
||||
manager._devices[device_id].auto_shutdown = update_data.auto_shutdown
|
||||
ds.auto_shutdown = update_data.auto_shutdown
|
||||
if update_data.zone_mode is not None:
|
||||
manager._devices[device_id].zone_mode = update_data.zone_mode
|
||||
ds.zone_mode = update_data.zone_mode
|
||||
|
||||
return _device_to_response(device)
|
||||
|
||||
@@ -420,7 +424,7 @@ async def get_device_brightness(
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
# Return cached hardware brightness if available (updated by SET endpoint)
|
||||
ds = manager._devices.get(device_id)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds and ds.hardware_brightness is not None:
|
||||
return {"brightness": ds.hardware_brightness}
|
||||
|
||||
@@ -465,13 +469,15 @@ async def set_device_brightness(
|
||||
except NotImplementedError:
|
||||
# Provider has no hardware brightness; use software brightness
|
||||
device.software_brightness = bri
|
||||
device.updated_at = __import__("datetime").datetime.utcnow()
|
||||
from datetime import datetime, timezone
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
store.save()
|
||||
if device_id in manager._devices:
|
||||
manager._devices[device_id].software_brightness = bri
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds:
|
||||
ds.software_brightness = bri
|
||||
|
||||
# Update cached hardware brightness
|
||||
ds = manager._devices.get(device_id)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds:
|
||||
ds.hardware_brightness = bri
|
||||
|
||||
@@ -499,7 +505,7 @@ async def get_device_power(
|
||||
|
||||
try:
|
||||
# Serial devices: use tracked state (no hardware query available)
|
||||
ds = manager._devices.get(device_id)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if device.device_type in ("adalight", "ambiled") and ds:
|
||||
return {"on": ds.power_on}
|
||||
|
||||
@@ -532,10 +538,10 @@ async def set_device_power(
|
||||
|
||||
try:
|
||||
# For serial devices, use the cached idle client to avoid port conflicts
|
||||
ds = manager._devices.get(device_id)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if device.device_type in ("adalight", "ambiled") and ds:
|
||||
if not on:
|
||||
await manager._send_clear_pixels(device_id)
|
||||
await manager.send_clear_pixels(device_id)
|
||||
ds.power_on = on
|
||||
else:
|
||||
provider = get_provider(device.device_type)
|
||||
|
||||
@@ -105,6 +105,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
adaptive_fps=target.adaptive_fps,
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -117,6 +118,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
picture_source_id=target.picture_source_id,
|
||||
key_colors_settings=_kc_settings_to_schema(target.settings),
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -127,6 +129,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -169,6 +172,7 @@ async def create_target(
|
||||
picture_source_id=data.picture_source_id,
|
||||
key_colors_settings=kc_settings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
|
||||
# Register in processor manager
|
||||
@@ -287,6 +291,7 @@ async def update_target(
|
||||
protocol=data.protocol,
|
||||
key_colors_settings=kc_settings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
|
||||
# Detect KC brightness VS change (inside key_colors_settings)
|
||||
@@ -461,11 +466,11 @@ async def get_target_colors(
|
||||
r=r, g=g, b=b,
|
||||
hex=f"#{r:02x}{g:02x}{b:02x}",
|
||||
)
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
return KeyColorsResponse(
|
||||
target_id=target_id,
|
||||
colors=colors,
|
||||
timestamp=datetime.utcnow(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -36,6 +36,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +71,7 @@ async def create_pattern_template(
|
||||
name=data.name,
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _pat_template_to_response(template)
|
||||
except ValueError as e:
|
||||
@@ -113,6 +115,7 @@ async def update_pattern_template(
|
||||
name=data.name,
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _pat_template_to_response(template)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -60,6 +60,7 @@ def _stream_to_response(s) -> PictureSourceResponse:
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
description=s.description,
|
||||
tags=getattr(s, 'tags', []),
|
||||
)
|
||||
|
||||
|
||||
@@ -196,6 +197,7 @@ async def create_picture_source(
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_source=data.image_source,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _stream_to_response(stream)
|
||||
except HTTPException:
|
||||
@@ -240,6 +242,7 @@ async def update_picture_source(
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_source=data.image_source,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _stream_to_response(stream)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -50,6 +50,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
)
|
||||
|
||||
|
||||
@@ -81,6 +82,7 @@ async def create_pp_template(
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _pp_template_to_response(template)
|
||||
except ValueError as e:
|
||||
@@ -119,6 +121,7 @@ async def update_pp_template(
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _pp_template_to_response(template)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Scene preset API routes — CRUD, capture, activate, recapture."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
@@ -45,6 +45,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
|
||||
"fps": t.fps,
|
||||
} for t in preset.targets],
|
||||
order=preset.order,
|
||||
tags=getattr(preset, 'tags', []),
|
||||
created_at=preset.created_at,
|
||||
updated_at=preset.updated_at,
|
||||
)
|
||||
@@ -69,13 +70,14 @@ async def create_scene_preset(
|
||||
target_ids = set(data.target_ids) if data.target_ids is not None else None
|
||||
targets = capture_current_snapshot(target_store, manager, target_ids)
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
preset = ScenePreset(
|
||||
id=f"scene_{uuid.uuid4().hex[:8]}",
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
targets=targets,
|
||||
order=store.count(),
|
||||
tags=data.tags if data.tags is not None else [],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -169,6 +171,7 @@ async def update_scene_preset(
|
||||
description=data.description,
|
||||
order=data.order,
|
||||
targets=new_targets,
|
||||
tags=data.tags,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
|
||||
|
||||
@@ -33,6 +33,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
|
||||
name=clock.name,
|
||||
speed=rt.speed if rt else clock.speed,
|
||||
description=clock.description,
|
||||
tags=getattr(clock, 'tags', []),
|
||||
is_running=rt.is_running if rt else True,
|
||||
elapsed_time=rt.get_time() if rt else 0.0,
|
||||
created_at=clock.created_at,
|
||||
@@ -67,6 +68,7 @@ async def create_sync_clock(
|
||||
name=data.name,
|
||||
speed=data.speed,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _to_response(clock, manager)
|
||||
except ValueError as e:
|
||||
@@ -103,6 +105,7 @@ async def update_sync_clock(
|
||||
name=data.name,
|
||||
speed=data.speed,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
# Hot-update runtime speed
|
||||
if data.speed is not None:
|
||||
|
||||
@@ -7,7 +7,7 @@ import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -18,7 +18,23 @@ from pydantic import BaseModel
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import get_auto_backup_engine, get_processor_manager
|
||||
from wled_controller.api.dependencies import (
|
||||
get_auto_backup_engine,
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
get_automation_store,
|
||||
get_color_strip_store,
|
||||
get_device_store,
|
||||
get_output_target_store,
|
||||
get_pattern_template_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_processor_manager,
|
||||
get_scene_preset_store,
|
||||
get_sync_clock_store,
|
||||
get_template_store,
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.api.schemas.system import (
|
||||
AutoBackupSettings,
|
||||
AutoBackupStatusResponse,
|
||||
@@ -104,7 +120,7 @@ async def health_check():
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.utcnow(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
version=__version__,
|
||||
)
|
||||
|
||||
@@ -124,6 +140,39 @@ async def get_version():
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/tags", tags=["Tags"])
|
||||
async def list_all_tags(_: AuthRequired):
|
||||
"""Get all tags used across all entities."""
|
||||
all_tags: set[str] = set()
|
||||
store_getters = [
|
||||
get_device_store, get_output_target_store, get_color_strip_store,
|
||||
get_picture_source_store, get_audio_source_store, get_value_source_store,
|
||||
get_sync_clock_store, get_automation_store, get_scene_preset_store,
|
||||
get_template_store, get_audio_template_store, get_pp_template_store,
|
||||
get_pattern_template_store,
|
||||
]
|
||||
for getter in store_getters:
|
||||
try:
|
||||
store = getter()
|
||||
except RuntimeError:
|
||||
continue
|
||||
# Each store has a different "get all" method name
|
||||
items = None
|
||||
for method_name in (
|
||||
"get_all_devices", "get_all_targets", "get_all_sources",
|
||||
"get_all_streams", "get_all_clocks", "get_all_automations",
|
||||
"get_all_presets", "get_all_templates",
|
||||
):
|
||||
fn = getattr(store, method_name, None)
|
||||
if fn is not None:
|
||||
items = fn()
|
||||
break
|
||||
if items:
|
||||
for item in items:
|
||||
all_tags.update(getattr(item, 'tags', []))
|
||||
return {"tags": sorted(all_tags)}
|
||||
|
||||
|
||||
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
|
||||
async def get_displays(
|
||||
_: AuthRequired,
|
||||
@@ -238,7 +287,7 @@ def get_system_performance(_: AuthRequired):
|
||||
ram_total_mb=round(mem.total / 1024 / 1024, 1),
|
||||
ram_percent=mem.percent,
|
||||
gpu=gpu,
|
||||
timestamp=datetime.utcnow(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@@ -318,14 +367,14 @@ def backup_config(_: AuthRequired):
|
||||
"format": "ledgrab-backup",
|
||||
"format_version": 1,
|
||||
"app_version": __version__,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
"store_count": len(stores),
|
||||
},
|
||||
"stores": stores,
|
||||
}
|
||||
|
||||
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H%M%S")
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-backup-{timestamp}.json"
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
@@ -62,7 +62,7 @@ async def list_templates(
|
||||
name=t.name,
|
||||
engine_type=t.engine_type,
|
||||
engine_config=t.engine_config,
|
||||
|
||||
tags=getattr(t, 'tags', []),
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
@@ -93,6 +93,7 @@ async def create_template(
|
||||
engine_type=template_data.engine_type,
|
||||
engine_config=template_data.engine_config,
|
||||
description=template_data.description,
|
||||
tags=template_data.tags,
|
||||
)
|
||||
|
||||
return TemplateResponse(
|
||||
@@ -100,7 +101,7 @@ async def create_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
|
||||
tags=getattr(template, 'tags', []),
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
@@ -130,6 +131,7 @@ async def get_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=getattr(template, 'tags', []),
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
@@ -151,6 +153,7 @@ async def update_template(
|
||||
engine_type=update_data.engine_type,
|
||||
engine_config=update_data.engine_config,
|
||||
description=update_data.description,
|
||||
tags=update_data.tags,
|
||||
)
|
||||
|
||||
return TemplateResponse(
|
||||
@@ -158,7 +161,7 @@ async def update_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
|
||||
tags=getattr(template, 'tags', []),
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
|
||||
@@ -51,6 +51,7 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
picture_source_id=d.get("picture_source_id"),
|
||||
scene_behavior=d.get("scene_behavior"),
|
||||
description=d.get("description"),
|
||||
tags=d.get("tags", []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -97,6 +98,7 @@ async def create_value_source(
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
tags=data.tags,
|
||||
)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
@@ -144,6 +146,7 @@ async def update_value_source(
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
tags=data.tags,
|
||||
)
|
||||
# Hot-reload running value streams
|
||||
pm.update_value_source(source_id)
|
||||
|
||||
@@ -19,6 +19,7 @@ class AudioSourceCreate(BaseModel):
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class AudioSourceUpdate(BaseModel):
|
||||
@@ -31,6 +32,7 @@ class AudioSourceUpdate(BaseModel):
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AudioSourceResponse(BaseModel):
|
||||
@@ -45,6 +47,7 @@ class AudioSourceResponse(BaseModel):
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class AudioTemplateCreate(BaseModel):
|
||||
engine_type: str = Field(description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1)
|
||||
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class AudioTemplateUpdate(BaseModel):
|
||||
@@ -22,6 +23,7 @@ class AudioTemplateUpdate(BaseModel):
|
||||
engine_type: Optional[str] = Field(None, description="Audio engine type")
|
||||
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AudioTemplateResponse(BaseModel):
|
||||
@@ -31,6 +33,7 @@ class AudioTemplateResponse(BaseModel):
|
||||
name: str = Field(description="Template name")
|
||||
engine_type: str = Field(description="Engine type identifier")
|
||||
engine_config: Dict = Field(description="Engine-specific configuration")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
@@ -39,6 +39,7 @@ class AutomationCreate(BaseModel):
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class AutomationUpdate(BaseModel):
|
||||
@@ -51,6 +52,7 @@ class AutomationUpdate(BaseModel):
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AutomationResponse(BaseModel):
|
||||
@@ -64,6 +66,7 @@ class AutomationResponse(BaseModel):
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
|
||||
is_active: bool = Field(default=False, description="Whether the automation is currently active")
|
||||
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
|
||||
|
||||
@@ -97,6 +97,7 @@ class ColorStripSourceCreate(BaseModel):
|
||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class ColorStripSourceUpdate(BaseModel):
|
||||
@@ -150,6 +151,7 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ColorStripSourceResponse(BaseModel):
|
||||
@@ -205,6 +207,7 @@ class ColorStripSourceResponse(BaseModel):
|
||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
@@ -18,6 +18,7 @@ class DeviceCreate(BaseModel):
|
||||
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
|
||||
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
@@ -32,6 +33,7 @@ class DeviceUpdate(BaseModel):
|
||||
send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)")
|
||||
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
@@ -125,6 +127,7 @@ class DeviceResponse(BaseModel):
|
||||
rgbw: bool = Field(default=False, description="RGBW mode (mock devices)")
|
||||
zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate")
|
||||
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class OutputTargetCreate(BaseModel):
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class OutputTargetUpdate(BaseModel):
|
||||
@@ -85,6 +86,7 @@ class OutputTargetUpdate(BaseModel):
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class OutputTargetResponse(BaseModel):
|
||||
@@ -107,6 +109,7 @@ class OutputTargetResponse(BaseModel):
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class PatternTemplateCreate(BaseModel):
|
||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="List of named rectangles")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class PatternTemplateUpdate(BaseModel):
|
||||
@@ -22,6 +23,7 @@ class PatternTemplateUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(None, description="List of named rectangles")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class PatternTemplateResponse(BaseModel):
|
||||
@@ -30,6 +32,7 @@ class PatternTemplateResponse(BaseModel):
|
||||
id: str = Field(description="Template ID")
|
||||
name: str = Field(description="Template name")
|
||||
rectangles: List[KeyColorRectangleSchema] = Field(description="List of named rectangles")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
@@ -18,6 +18,7 @@ class PictureSourceCreate(BaseModel):
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class PictureSourceUpdate(BaseModel):
|
||||
@@ -31,6 +32,7 @@ class PictureSourceUpdate(BaseModel):
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class PictureSourceResponse(BaseModel):
|
||||
@@ -45,6 +47,7 @@ class PictureSourceResponse(BaseModel):
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Stream description")
|
||||
|
||||
@@ -14,6 +14,7 @@ class PostprocessingTemplateCreate(BaseModel):
|
||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class PostprocessingTemplateUpdate(BaseModel):
|
||||
@@ -22,6 +23,7 @@ class PostprocessingTemplateUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class PostprocessingTemplateResponse(BaseModel):
|
||||
@@ -30,6 +32,7 @@ class PostprocessingTemplateResponse(BaseModel):
|
||||
id: str = Field(description="Template ID")
|
||||
name: str = Field(description="Template name")
|
||||
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
@@ -20,6 +20,7 @@ class ScenePresetCreate(BaseModel):
|
||||
name: str = Field(description="Preset name", min_length=1, max_length=100)
|
||||
description: str = Field(default="", max_length=500)
|
||||
target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class ScenePresetUpdate(BaseModel):
|
||||
@@ -29,6 +30,7 @@ class ScenePresetUpdate(BaseModel):
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
order: Optional[int] = None
|
||||
target_ids: Optional[List[str]] = Field(None, description="Update target list: keep state for existing, capture fresh for new, drop removed")
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ScenePresetResponse(BaseModel):
|
||||
@@ -39,6 +41,7 @@ class ScenePresetResponse(BaseModel):
|
||||
description: str
|
||||
targets: List[TargetSnapshotSchema]
|
||||
order: int
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class SyncClockCreate(BaseModel):
|
||||
name: str = Field(description="Clock name", min_length=1, max_length=100)
|
||||
speed: float = Field(default=1.0, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class SyncClockUpdate(BaseModel):
|
||||
@@ -20,6 +21,7 @@ class SyncClockUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
|
||||
speed: Optional[float] = Field(None, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class SyncClockResponse(BaseModel):
|
||||
@@ -29,6 +31,7 @@ class SyncClockResponse(BaseModel):
|
||||
name: str = Field(description="Clock name")
|
||||
speed: float = Field(description="Speed multiplier")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
is_running: bool = Field(True, description="Whether clock is currently running")
|
||||
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
|
||||
@@ -13,6 +13,7 @@ class TemplateCreate(BaseModel):
|
||||
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
|
||||
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
@@ -22,6 +23,7 @@ class TemplateUpdate(BaseModel):
|
||||
engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)")
|
||||
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
@@ -31,6 +33,7 @@ class TemplateResponse(BaseModel):
|
||||
name: str = Field(description="Template name")
|
||||
engine_type: str = Field(description="Engine type identifier")
|
||||
engine_config: Dict = Field(description="Engine-specific configuration")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
@@ -29,6 +29,7 @@ class ValueSourceCreate(BaseModel):
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class ValueSourceUpdate(BaseModel):
|
||||
@@ -53,6 +54,7 @@ class ValueSourceUpdate(BaseModel):
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ValueSourceResponse(BaseModel):
|
||||
@@ -75,6 +77,7 @@ class ValueSourceResponse(BaseModel):
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user