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:
2026-03-09 22:20:19 +03:00
parent 2712c6682e
commit 30fa107ef7
120 changed files with 2471 additions and 1949 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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))

View File

@@ -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:

View File

@@ -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(

View File

@@ -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,

View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
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.110.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")

View File

@@ -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")

View File

@@ -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")